diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 84daffc69f..0000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 51e2232d24..6f7e892284 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.1 FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29f63b54b5..9181275269 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,7 +15,6 @@ // uncomment and edit the path in order to pass through local USB serial to the container // , "--device=/dev/ttyACM0" ], - "appPort": 6052, // if you are using avahi in the host device, uncomment these to allow the // devcontainer to find devices via mdns //"mounts": [ @@ -41,7 +40,11 @@ ], "settings": { "python.languageServer": "Pylance", - "python.pythonPath": "/usr/bin/python3", + // Use the container's pre-provisioned venv (built by the Dockerfile, outside the + // bind-mounted workspace) rather than a ./venv that may leak in from the host and + // mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv). + "python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python", + "python.terminal.activateEnvironment": true, "pylint.args": [ "--rcfile=${workspaceFolder}/pyproject.toml" ], diff --git a/.gitattributes b/.gitattributes index 1b3fd332b4..8171cd910f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ # Normalize line endings to LF in the repository * text eol=lf *.png binary +*.gif binary +*.apng binary diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 494c0cebe8..133d7ca8d8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false 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/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml new file mode 100644 index 0000000000..6cbb87cc66 --- /dev/null +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -0,0 +1,49 @@ +name: Cache nRF Connect SDK +description: > + Resolve the pinned sdk-nrf version and cache the native sdk-nrf install + (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf. + Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and, + once the component tests build natively, their batches) shares one cache. + Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have + the Python venv already restored. +inputs: + restore-only: + description: > + When "true", only restore -- never save the cache, even on dev. Use from + jobs that may not produce a complete install (e.g. a component batch + that fails mid-install), so a partial install is never written. + default: "false" +runs: + using: composite + steps: + - name: Resolve sdk-nrf and toolchain versions for cache key + # Both versions are pinned in code, not in any file that feeds the + # other cache keys, so resolve them explicitly. Keying on them means + # the cache invalidates when either is bumped (actions/cache never + # overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + version=$(python -c ' + from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION + from esphome.components.nrf52.framework import TOOLCHAIN_VERSION + print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")') + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it + # lives in the default-branch scope readable by all PRs); PRs are + # restore-only and never push multi-GB artifacts into their own scope. + - name: Cache nRF Connect SDK install (write on dev) + if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} + - name: Cache nRF Connect SDK install (restore-only off dev) + if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.esphome-sdk-nrf + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 66d016b42d..9d6dc5301c 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,12 +17,12 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -32,9 +32,12 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 81bb77843d..4406370a27 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -147,19 +147,9 @@ async function detectCoreChanges(changedFiles) { } // Strategy: PR size detection -async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { +async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { const labels = new Set(); - if (totalChanges <= SMALL_PR_THRESHOLD) { - labels.add('small-pr'); - return labels; - } - - if (totalChanges <= MEDIUM_PR_THRESHOLD) { - labels.add('medium-pr'); - return labels; - } - const testAdditions = prFiles .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.additions || 0), 0); @@ -167,7 +157,24 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange .filter(file => file.filename.startsWith('tests/')) .reduce((sum, file) => sum + (file.deletions || 0), 0); - const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions); + const nonTestAdditions = totalAdditions - testAdditions; + const nonTestDeletions = totalDeletions - testDeletions; + + // small/medium count churn (additions + deletions) so a balanced refactor isn't undersized. + const nonTestChurn = nonTestAdditions + nonTestDeletions; + + if (nonTestChurn <= SMALL_PR_THRESHOLD) { + labels.add('small-pr'); + return labels; + } + + if (nonTestChurn <= MEDIUM_PR_THRESHOLD) { + labels.add('medium-pr'); + return labels; + } + + // too-big uses net line delta (additions - deletions), matching the review message in reviews.js. + const nonTestChanges = nonTestAdditions - nonTestDeletions; // Don't add too-big if mega-pr label is already present if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 9769cd8060..c8bdcfb2f3 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -123,7 +123,7 @@ module.exports = async ({ github, context }) => { detectNewComponents(github, context, prFiles), detectNewPlatforms(github, context, prFiles, apiData), detectCoreChanges(changedFiles), - detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), + detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectDashboardChanges(changedFiles), detectGitHubActionsChanges(changedFiles), detectCodeOwner(github, context, changedFiles), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index 02d69ca95e..aab1827c44 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -const { detectNewPlatforms, detectNewComponents } = require('../detectors'); +const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // to check for CONFIG_SCHEMA in newly added files. @@ -145,3 +145,79 @@ describe('detectNewComponents', () => { assert.equal(result.labels.size, 0); }); }); + +// --------------------------------------------------------------------------- +// detectPRSize +// --------------------------------------------------------------------------- + +describe('detectPRSize', () => { + const SMALL = 30; + const MEDIUM = 100; + const TOO_BIG = 1000; + + function size(prFiles, isMegaPR = false) { + const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0); + const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0); + return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG); + } + + it('counts only non-test changes toward small-pr', async () => { + // 10 source + 5000 test lines -> non-test churn of 10 is still small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('small-pr')); + assert.equal(labels.size, 1); + }); + + it('counts additions and deletions as churn (not net delta)', async () => { + // A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('labels medium-pr when non-test changes exceed small threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('medium-pr')); + assert.equal(labels.size, 1); + }); + + it('uses net delta (not churn) for too-big', async () => { + // 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 }, + ]); + assert.equal(labels.size, 0); + }); + + it('labels too-big when non-test changes exceed the big threshold', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 }, + ]); + assert.ok(labels.has('too-big')); + assert.equal(labels.size, 1); + }); + + it('does not label too-big when mega-pr is set', async () => { + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); + + it('produces no size label for a large mega-pr in the gap above medium', async () => { + // Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big. + const labels = await size([ + { filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 }, + ], true); + assert.equal(labels.size, 0); + }); +}); diff --git a/.github/scripts/detect-tags.js b/.github/scripts/detect-tags.js index 3933776c61..99caccc2f8 100644 --- a/.github/scripts/detect-tags.js +++ b/.github/scripts/detect-tags.js @@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) { */ function hasDashboardChanges(changedFiles) { return changedFiles.some(file => - file.startsWith('esphome/dashboard/') || file.startsWith('esphome/components/dashboard_import/') ); } 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..58fc83e3f5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,17 +21,20 @@ 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull-request-only workflow: a save could never be shared and + # would only consume quota. + save-cache: "false" # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. 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 7d4b850356..2740ca76ca 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,25 +1,38 @@ --- 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 @@ -48,13 +61,13 @@ jobs: 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Determine tag and whether to push id: tag @@ -83,7 +96,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -96,7 +109,26 @@ jobs: --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ --registry ghcr \ - build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} + 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 @@ -113,16 +145,16 @@ jobs: - "ha-addon" - "docker" 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -135,3 +167,51 @@ jobs: --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..ac0322e2fa 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 @@ -60,7 +60,7 @@ jobs: if: steps.pr.outputs.skip != 'true' uses: ./.github/actions/restore-python with: - python-version: "3.11" + python-version: "3.12" cache-key: ${{ hashFiles('.cache-key') }} - name: Download memory analysis artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a57be34e9b..adf98478fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ permissions: contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write env: - DEFAULT_PYTHON: "3.11" - PYUPGRADE_TARGET: "--py311-plus" + DEFAULT_PYTHON: "3.12" + PYUPGRADE_TARGET: "--py312-plus" concurrency: # yamllint disable-line rule:line-length @@ -28,18 +28,18 @@ 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 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -49,9 +49,12 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -74,7 +77,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 +100,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: @@ -112,7 +115,7 @@ jobs: script/build_codeowners.py --check script/build_language_schema.py --check script/generate-esp32-boards.py --check - script/generate-rp2040-boards.py --check + script/generate-rp2-boards.py --check script/ci_check_duplicate_test_ids.py import-time: @@ -124,7 +127,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,17 +155,17 @@ 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 path: device-builder - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Set up uv @@ -171,9 +174,12 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -203,7 +209,7 @@ jobs: fail-fast: false matrix: python-version: - - "3.11" + - "3.12" - "3.13" - "3.14" os: @@ -225,7 +231,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 @@ -250,7 +256,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -270,8 +276,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 +291,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 @@ -295,7 +301,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -324,8 +330,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 @@ -339,7 +345,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -357,24 +363,27 @@ 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + # Pull request saves land in per-PR scopes nothing else can + # reuse; dev pushes seed the shared copy instead. + save-cache: ${{ github.event_name != 'pull_request' }} # Pin uv version so the action does not have to fetch the # manifest from raw.githubusercontent.com on every cache # miss; that fetch flakes on Windows runners. @@ -409,7 +418,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 +447,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 +465,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 + uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4 with: run: | . venv/bin/activate @@ -475,6 +484,8 @@ jobs: GH_TOKEN: ${{ github.token }} # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache. ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + # nrf52-tidy installs sdk-nrf natively; pin it to a cacheable path. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: 2 @@ -491,12 +502,12 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ZEPHYR options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 - pio_cache_key: tidy-zephyr + cache_sdk_nrf: true ignore_errors: false 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 @@ -509,25 +520,28 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio 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: framework: arduino + - name: Cache nRF Connect SDK install + if: matrix.cache_sdk_nrf + uses: ./.github/actions/cache-sdk-nrf + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -537,15 +551,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 +594,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 +606,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 +617,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 +674,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 +686,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 +697,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 +758,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 +770,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 +781,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 @@ -811,17 +810,24 @@ jobs: if: always() test-build-components-split: - name: Test components batch (${{ matrix.components }}) + name: Test components batch (${{ matrix.batch.components }}) runs-on: ubuntu-24.04 needs: - 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 + # nrf52 component builds install sdk-nrf natively; pin it to the shared + # cacheable path so the restore below lands where the build looks. + ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} matrix: - components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} + batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: - name: Show disk space run: | @@ -829,21 +835,38 @@ jobs: df -h - name: List components - run: echo ${{ matrix.components }} + run: echo ${{ matrix.batch.components }} - - name: Cache apt packages - uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3 - with: - packages: libsdl2-dev - version: 1.0 + - name: Install apt packages + # Not cached: this job is pull-request-only, so a cache save could + # never be shared and would only consume quota. + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libsdl2-dev ccache - 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) + # Only batches whose test platforms include esp32 need the native + # ESP-IDF install; never save -- just reuse the shared install the + # dev tidy jobs already cached when present. + if: matrix.batch.needs_idf + uses: ./.github/actions/cache-esp-idf + with: + restore-only: true + - name: Cache nRF Connect SDK install (restore-only) + # Only batches whose test platforms include nrf52 need the native + # sdk-nrf install; never save -- just reuse the shared install the + # dev nrf52 tidy job cached when present. + if: matrix.batch.needs_nrf + uses: ./.github/actions/cache-sdk-nrf + with: + restore-only: true - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -874,7 +897,7 @@ jobs: fi # Convert space-separated components to comma-separated for Python script - components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',') + components_csv=$(echo "${{ matrix.batch.components }}" | tr ' ' ',') # Only isolate directly changed components when targeting dev branch # For beta/release branches, group everything for faster CI @@ -947,23 +970,27 @@ jobs: echo "All components in this batch are validate-only -- skipping compile stage." fi - test-native-idf: - name: Test components with native ESP-IDF + - name: Print ccache statistics + # esphome stores the cache under the IDF tools path; expand the leading + # ~ in ESPHOME_ESP_IDF_PREFIX so ccache reads the dir the build used. + run: CCACHE_DIR="${ESPHOME_ESP_IDF_PREFIX/#\~/$HOME}/ccache" ccache -s + + 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 +998,53 @@ 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 "" + # 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 - # 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 + pre-commit-seed-cache: + name: Seed pre-commit cache + runs-on: ubuntu-latest + needs: + - common + # Saves a dev-scoped pre-commit cache that pull request runs can + # restore, since pre-commit.ci lite itself never runs on dev pushes. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + steps: + - name: Check out code from GitHub + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Restore Python + uses: ./.github/actions/restore-python with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache pre-commit environments + id: cache-pre-commit + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the restore key in pre-commit-ci-lite + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit hook environments + if: steps.cache-pre-commit.outputs.cache-hit != 'true' + run: | + python -m pip install pre-commit + pre-commit install-hooks pre-commit-ci-lite: name: pre-commit.ci lite @@ -1041,15 +1055,28 @@ 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: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + # Inlined from esphome/pre-commit-action with a restore-only cache + # step: the pre-commit-seed-cache job owns saving this cache, so + # pull request runs never write per-PR copies. + - name: Restore pre-commit cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/pre-commit + # Must match the key pre-commit-seed-cache saves + # yamllint disable-line rule:line-length + key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit env: - SKIP: pylint,clang-tidy-hash,ci-custom + SKIP: pylint,ci-custom + run: | + python -m pip install pre-commit + pre-commit run --show-diff-on-failure --color=always --all-files - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() @@ -1067,7 +1094,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 }} @@ -1143,7 +1170,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1167,7 +1194,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1209,7 +1236,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1249,14 +1276,14 @@ 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: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -1318,7 +1345,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 +1392,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 +1408,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..e718b481e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,11 +52,11 @@ 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 - uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml deleted file mode 100644 index ffd5ec7bd9..0000000000 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Add Dashboard Deprecation Comment - -on: - pull_request_target: - types: [opened, synchronize] - -# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with -# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes. -permissions: {} - -jobs: - dashboard-deprecation-comment: - name: Dashboard deprecation comment - runs-on: ubuntu-latest - # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably - # roll up everything merged into dev since the last cut, which can - # include dashboard changes that have already been reviewed once. - # The bot's purpose is to warn new contributors before they invest - # time -- that only applies to PRs entering dev. - if: github.event.pull_request.base.ref == 'dev' - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources - # the issues.*Comment APIs require the pull-requests scope, not issues. - permission-pull-requests: write - - - name: Add dashboard deprecation comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - const commentMarker = ""; - - const commentBody = `Thanks for opening this PR! - - Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository. - - What this means for your PR: - - - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here. - - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life. - - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix. - - We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land. - - --- - (Added by the PR bot) - - ${commentMarker}`; - - async function getDashboardChanges(github, owner, repo, prNumber) { - const changedFiles = await github.paginate( - github.rest.pulls.listFiles, - { - owner: owner, - repo: repo, - pull_number: prNumber, - per_page: 100, - } - ); - - return changedFiles.filter(file => - file.filename.startsWith('esphome/dashboard/') || - file.filename.startsWith('tests/dashboard/') - ); - } - - async function findBotComment(github, owner, repo, prNumber) { - const comments = await github.paginate( - github.rest.issues.listComments, - { - owner: owner, - repo: repo, - issue_number: prNumber, - per_page: 100, - } - ); - - return comments.find(comment => - comment.body.includes(commentMarker) && comment.user.type === "Bot" - ); - } - - const prNumber = context.payload.pull_request.number; - const { owner, repo } = context.repo; - - const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber); - const existingComment = await findBotComment(github, owner, repo, prNumber); - - if (dashboardChanges.length === 0) { - // PR doesn't (or no longer) touches the legacy dashboard. If we previously - // commented (e.g. files were removed in a later push), leave the comment in - // place for history rather than thrash on edit/delete. - return; - } - - if (existingComment) { - if (existingComment.body === commentBody) { - return; - } - await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: existingComment.id, - body: commentBody, - }); - } else { - await github.rest.issues.createComment({ - owner: owner, - repo: repo, - issue_number: prNumber, - body: commentBody, - }); - } 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..b63067ab4b 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,9 +60,9 @@ 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - name: Build @@ -92,22 +92,22 @@ 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 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -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 @@ -178,17 +178,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ab1ce2b587..2f350d09b3 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -28,16 +28,16 @@ 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 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.14" @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``pre-commit`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b6278e6b5..da424f516f 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 @@ -40,7 +40,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py311-plus] + args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: @@ -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..75a9cdb2bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,12 +9,12 @@ This document provides essential context for AI models interacting with this pro ## 2. Core Technologies & Stack -* **Languages:** Python (>=3.11), C++ (gnu++20) +* **Languages:** Python (>=3.12), C++ (gnu++20) * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Configuration:** YAML. * **Key Libraries/Dependencies:** - * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `tornado` (for the web server), `aioesphomeapi` (for the native API). + * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `aioesphomeapi` (for the native API). * **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server). * **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies). * **Communication Protocols:** Protobuf (for native API), MQTT, HTTP. @@ -35,7 +35,6 @@ This document provides essential context for AI models interacting with this pro 2. **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management. 3. **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management. 4. **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration. - 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates. * **Platform Support:** 1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3). @@ -59,6 +58,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: @@ -415,13 +427,14 @@ This document provides essential context for AI models interacting with this pro When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes. - * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`: + * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`. + + All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`): ```yaml - # test.esp32-idf.yaml — use packages for buses + # test.esp32-idf.yaml — everything included via named packages packages: uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml - - <<: !include common.yaml + my_component: !include common.yaml ``` ```yaml # common.yaml — component config only, NO bus definitions @@ -443,7 +456,6 @@ This document provides essential context for AI models interacting with this pro * **Debug Tools:** - `esphome config .yaml` to validate configuration. - `esphome compile .yaml` to compile without uploading. - - Check the Dashboard for real-time logs. - Use component-specific debug logging. * **Common Issues:** - **Import Errors**: Check component dependencies and `PYTHONPATH`. @@ -645,7 +657,7 @@ This document provides essential context for AI models interacting with this pro If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs. **Why this matters:** - - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec + - Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec - `CORE.data` automatically clears between runs - Namespacing under `DOMAIN` prevents key collisions between components - `@dataclass` provides type safety and cleaner attribute access @@ -698,3 +710,9 @@ This document provides essential context for AI models interacting with this pro _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate ``` +## 9. English Language + +The project uses English for non-code content. When drafting documentation, code comments, commit messages, +PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English, +using standard technical terms only when required. Ensure the text is readily comprehensible to a wide +audience, including non-native English speakers. diff --git a/CODEOWNERS b/CODEOWNERS index 10128c64e5..0f43cd9749 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -122,7 +122,9 @@ esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger esphome/components/cst226/* @clydebarrow +esphome/components/cst328/* @latonita esphome/components/cst816/* @clydebarrow +esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz esphome/components/current_based/* @djwmarcx esphome/components/dac7678/* @NickB1 @@ -185,6 +187,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi +esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/fs3000/* @kahrendt @@ -206,6 +209,7 @@ esphome/components/gree/switch/* @nagyrobi esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_tb6612fng/* @max246 esphome/components/growatt_solar/* @leeuwte +esphome/components/gsl3670/* @clydebarrow esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn @@ -266,6 +270,7 @@ esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core esphome/components/ir_rf_proxy/* @kbx81 +esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 esphome/components/json/* @esphome/core esphome/components/kamstrup_kmp/* @cfeenstra1024 @@ -385,6 +390,7 @@ esphome/components/pcm5122/* @remcom esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 +esphome/components/pixoo/* @jesserockz esphome/components/pm1006/* @habbie esphome/components/pm2005/* @andrewjswan esphome/components/pmsa003i/* @sjtrny @@ -400,10 +406,12 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core +esphome/components/provisioning/* @esphome/core esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje esphome/components/qspi_dbi/* @clydebarrow @@ -421,7 +429,7 @@ esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt esphome/components/router/speaker/* @kahrendt -esphome/components/rp2040/* @jesserockz +esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz @@ -445,7 +453,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 @@ -497,6 +505,7 @@ esphome/components/ssd1331_base/* @kbx81 esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_spi/* @kbx81 +esphome/components/st7123/* @miniskipper esphome/components/st7567_base/* @latonita esphome/components/st7567_i2c/* @latonita esphome/components/st7567_spi/* @latonita @@ -561,6 +570,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 @@ -577,6 +587,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54 esphome/components/watchdog/* @oarcher esphome/components/water_heater/* @dhoeben esphome/components/waveshare_epaper/* @clydebarrow +esphome/components/waveshare_io_ch32v003/* @latonita esphome/components/web_server/ota/* @esphome/core esphome/components/web_server_base/* @esphome/core esphome/components/web_server_idf/* @dentra diff --git a/Doxyfile b/Doxyfile index c5cae055e1..46f96b459a 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.5 +PROJECT_NUMBER = 2026.7.0 # 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..5816f38176 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,148 @@ +# 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. + +## The web server is an open HTTP API by design + +The `web_server` component exposes a plain HTTP interface for viewing and +controlling entities, and, when the `web_server` OTA platform is enabled, for +uploading firmware at `/update`. Its only access controls are the optional +`web_server` `auth:` credentials and the network the device sits on. + +When `auth:` is not configured, every endpoint is reachable by any client that +can reach the device. This is intentional; enabling `web_server` without `auth:` +is choosing an open control surface, in the same way that running native OTA +without a password leaves OTA open. The API is documented and is meant to be +called by other devices, scripts, and pages. + +As defense-in-depth, the web server checks the `Origin` header on browser requests +to its entity control and state endpoints: a request whose `Origin` does not match +the address the device is served on is rejected, and the `allowed_origins` option +widens that list. This blocks the common "confused deputy" (CSRF) case where a page +the operator visits drives the device through their browser. It is **not** an +authentication boundary: it only constrains browsers. Any client that omits the +`Origin` header — `curl`, scripts, or other non-browser callers on the same +network — reaches every endpoint exactly as before. The check also does not cover +the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer` +validation. The following are therefore **not** vulnerabilities in this repository: + +- Requests without an `Origin` header (for example `curl`) reaching the control + endpoints, whether or not `web_server` `auth:` is set. +- Requests from an origin the operator added to `allowed_origins`. +- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when + web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not + covered by the `Origin` check; this is the same exposure as running OTA without a + password. + +The supported defenses are `web_server` `auth:`, protecting OTA (a web password or +a native OTA password), and keeping devices on a trusted, segmented network. See +the security best practices guide linked above. + +What remains in scope is bypassing `web_server` `auth:` when it *is* configured, +and any memory-safety or protocol bug in the server reachable without credentials. + +This section documents the current design and scope; it is not a judgment that the +design is optimal or that it will not change. + +## 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). +- Access to the device web server or its web OTA endpoint by non-browser clients + (those that send no `Origin` header). The web server is an open HTTP API by + design (see above); browser cross-origin requests are blocked by default, but the + real controls are `web_server` `auth:` and network isolation. +- Anything in the dashboard / device-builder — report that in its own repository + (linked at the top). +- 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 d0f2f4d1a1..84fd658594 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ ARG BUILD_VERSION=dev -ARG BUILD_BASE_VERSION=2026.06.0 +ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_TYPE=docker FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker @@ -11,16 +11,6 @@ FROM base-source-${BUILD_TYPE} AS base RUN git config --system --add safe.directory "*" \ && git config --system advice.detachedHead false -# Install build tools for Python packages that require compilation -# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager). -# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can -# 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 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 RUN pip install --no-cache-dir -U pip uv==0.10.1 @@ -32,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.1.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.6.1 RUN \ platformio settings set enable_telemetry No \ diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 18baf40c29..c88a78f97e 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,6 +21,11 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native toolchain installs on the persistent cache root, not the +# container's ephemeral user cache dir (re-downloaded on every restart). +export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf" +export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf" + # If /build is mounted, use that as the build path # otherwise use path in /config (so that builds aren't lost on container restart) if [[ -d /build ]]; then diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run index d4628ffa83..20fada5f13 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run @@ -15,18 +15,15 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" +# Keep the native toolchain installs on the persistent /data volume, not the +# container's ephemeral user cache dir (wiped on every add-on update/restart). +export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf +export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf + if bashio::config.true 'leave_front_door_open'; then export DISABLE_HA_AUTHENTICATION=true fi -if bashio::config.true 'streamer_mode'; then - export ESPHOME_STREAMER_MODE=true -fi - -if bashio::config.has_value 'relative_url'; then - export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url') -fi - if bashio::config.has_value 'default_compile_process_limit'; then export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit') else 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..38e96630ba --- /dev/null +++ b/docker/test_configs/ln882x-arduino.yaml @@ -0,0 +1,7 @@ +esphome: + name: docker-test-ln882x-arduino + +ln882x: + board: generic-ln882h + +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 bec00cca60..4abd18d239 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -52,11 +52,6 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_RP2040, SECRETS_FILES, Toolchain, ) @@ -230,8 +225,9 @@ def _discover_mac_suffix_devices() -> list[str] | None: Returns: - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, - mDNS disabled, or ``CORE.address`` is already an IP). Callers should - then fall back to whatever default OTA address they normally use. + mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address). + Callers should then fall back to whatever default OTA address they + normally use. - ``[]`` when discovery ran but found nothing. Callers should NOT fall back to the base name: with ``name_add_mac_suffix`` enabled, the base name by definition doesn't exist on the network. @@ -241,7 +237,7 @@ def _discover_mac_suffix_devices() -> list[str] | None: ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we already have without opening a second Zeroconf client. """ - if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()): + if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()): return None from esphome.zeroconf import discover_mdns_devices @@ -268,6 +264,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]: return _resolve_with_cache(CORE.address, purpose) +def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: + """Build the error when a default device target produced no usable host. + + When the OTA default was requested and the address resolves but the config + lacks the transport the purpose needs (``api:`` for logs, an ``ota:`` + platform for uploads), name that gap instead of the misleading + "could not be resolved" / set-use_address hint. + """ + if "OTA" in defaults and has_resolvable_address(): + if purpose == Purpose.LOGGING and not has_api(): + return ( + "Cannot view logs over the network: no 'api:' component is " + "configured. Network log streaming requires the native API; add " + "an 'api:' component, enable MQTT logging, or view logs over USB." + ) + if purpose == Purpose.UPLOADING and not has_ota(): + return ( + "Cannot upload over the network: no 'ota:' platform is " + "configured. Add an 'ota:' platform, or upload over USB." + ) + if CORE.dashboard: + hint = "If you know the IP, set 'use_address' in your network config." + else: + hint = "If you know the IP, try --device " + return ( + f"All specified devices {defaults} could not be resolved. " + f"Is the device connected to the network? {hint}" + ) + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, @@ -317,14 +343,7 @@ def choose_upload_log_host( else: resolved.append(device) if not resolved: - if CORE.dashboard: - hint = "If you know the IP, set 'use_address' in your network config." - else: - hint = "If you know the IP, try --device " - raise EsphomeError( - f"All specified devices {defaults} could not be resolved. " - f"Is the device connected to the network? {hint}" - ) + raise EsphomeError(_unresolved_default_error(purpose, defaults)) return resolved # No devices specified, show interactive chooser @@ -336,7 +355,7 @@ def choose_upload_log_host( bootsel_permission_error = False if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2 and (picotool := _find_picotool()) is not None ): bootsel = detect_rp2040_bootsel(picotool) @@ -383,7 +402,7 @@ def choose_upload_log_host( # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found if ( purpose == Purpose.UPLOADING - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 + and CORE.is_rp2 and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) ): if bootsel_permission_error: @@ -485,17 +504,22 @@ def has_mdns() -> bool: def has_non_ip_address() -> bool: - """Check if CORE.address is set and is not an IP address.""" + """Check if ``CORE.address`` is set and is not an IP address.""" return CORE.address is not None and not is_ip_address(CORE.address) +def has_mdns_address() -> bool: + """Check if ``CORE.address`` is a ``.local`` mDNS hostname.""" + return CORE.address is not None and CORE.address.endswith(".local") + + def has_ip_address() -> bool: - """Check if CORE.address is a valid IP address.""" + """Check if ``CORE.address`` is a valid IP address.""" return CORE.address is not None and is_ip_address(CORE.address) def has_resolvable_address() -> bool: - """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address).""" + """Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address).""" # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver if CORE.address is None: @@ -504,7 +528,7 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True - # The dashboard pre-resolves the device and passes the IPs via + # device-builder 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): @@ -514,7 +538,7 @@ def has_resolvable_address() -> bool: return True # .local mDNS hostnames are only resolvable if mDNS is enabled - return not CORE.address.endswith(".local") + return not has_mdns_address() def has_name_add_mac_suffix() -> bool: @@ -961,7 +985,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int: # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # the upload target, but 'nobuild' skips the build phase that creates it. # Create it here so the upload doesn't fail. - if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040: + if CORE.is_rp2: idedata = toolchain.get_idedata(config) build_dir = Path(idedata.firmware_elf_path).parent firmware_bin = build_dir / "firmware.bin" @@ -1146,10 +1170,10 @@ def upload_program( check_permissions(host) exit_code = 1 - if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266): + if CORE.is_esp32 or CORE.is_esp8266: file = getattr(args, "file", None) exit_code = upload_using_esptool(config, host, file, args.upload_speed) - elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny: + elif CORE.is_rp2 or CORE.is_libretiny: exit_code = upload_using_platformio(config, host) # else: Unknown target platform, exit_code remains 1 @@ -1623,10 +1647,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: # After BOOTSEL upload, wait for a new serial port to appear # so it shows up in the log chooser - if ( - successful_device is None - and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040 - ): + if successful_device is None and CORE.is_rp2: _wait_for_serial_port(known_ports=pre_upload_ports) # If exactly one new serial port appeared, use it directly serial_ports = get_serial_ports() @@ -1709,9 +1730,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: def command_dashboard(args: ArgsProtocol) -> int | None: - from esphome.dashboard import dashboard - - return dashboard.start_dashboard(args) + raise EsphomeError( + "The built-in dashboard has been removed from ESPHome. " + "Install and run ESPHome Device Builder instead:\n" + " pip install esphome-device-builder\n" + " esphome-device-builder\n" + "See https://github.com/esphome/device-builder for more information." + ) def run_multiple_configs( @@ -2367,50 +2392,31 @@ def parse_args(argv): ) parser_clean_all = subparsers.add_parser( - "clean-all", help="Clean all build and platform files." + "clean-all", + help="Clean all build and platform files, including machine-global " + "toolchain caches shared by all configurations, so other projects will " + "re-download them on next build.", ) parser_clean_all.add_argument( "configuration", help="Your YAML file or configuration directory.", nargs="*" ) - parser_dashboard = subparsers.add_parser( - "dashboard", help="Create a simple web server for a dashboard." + # The dashboard moved to ESPHome Device Builder; the command is kept only to + # print a redirect (see command_dashboard). Accept and ignore the old flags + # so legacy invocations reach that message instead of failing on argparse + # "unrecognized arguments". + parser_dashboard = subparsers.add_parser("dashboard") + parser_dashboard.add_argument("configuration", nargs="?", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--port", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--address", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--username", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--password", help=argparse.SUPPRESS) + parser_dashboard.add_argument("--socket", help=argparse.SUPPRESS) + parser_dashboard.add_argument( + "--open-ui", action="store_true", help=argparse.SUPPRESS ) parser_dashboard.add_argument( - "configuration", help="Your YAML configuration file directory." - ) - parser_dashboard.add_argument( - "--port", - help="The HTTP port to open connections on. Defaults to 6052.", - type=int, - default=6052, - ) - parser_dashboard.add_argument( - "--address", - help="The address to bind to.", - type=str, - default="0.0.0.0", - ) - parser_dashboard.add_argument( - "--username", - help="The optional username to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--password", - help="The optional password to require for authentication.", - type=str, - default="", - ) - parser_dashboard.add_argument( - "--open-ui", help="Open the dashboard UI in a browser.", action="store_true" - ) - parser_dashboard.add_argument( - "--ha-addon", help=argparse.SUPPRESS, action="store_true" - ) - parser_dashboard.add_argument( - "--socket", help="Make the dashboard serve under a unix socket", type=str + "--ha-addon", action="store_true", help=argparse.SUPPRESS ) parser_vscode = subparsers.add_parser("vscode") @@ -2505,11 +2511,7 @@ def run_esphome(argv): elif args.quiet: args.log_level = "CRITICAL" - setup_log( - log_level=args.log_level, - # Show timestamp for dashboard access logs - include_timestamp=args.command == "dashboard", - ) + setup_log(log_level=args.log_level) if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py index fbcbeeca61..03da86de94 100644 --- a/esphome/analyze_memory/ram_strings.py +++ b/esphome/analyze_memory/ram_strings.py @@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266. from __future__ import annotations from collections import defaultdict -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from pathlib import Path import re @@ -65,6 +65,7 @@ class RamSymbol: size: int section: str demangled: str = "" # Demangled name, set after batch demangling + aliases: list[str] = field(default_factory=list) # Other names at same address class RamStringsAnalyzer: @@ -235,6 +236,11 @@ class RamStringsAnalyzer: except (subprocess.CalledProcessError, FileNotFoundError): return + # Track symbols by address so aliases (multiple names for the same + # object, e.g. the newlib __lock___* mutexes that all alias one + # StaticSemaphore_t) are reported once instead of once per name. + symbols_by_addr: dict[int, RamSymbol] = {} + for line in output.split("\n"): parts = line.split() if len(parts) < 4: @@ -253,6 +259,18 @@ class RamStringsAnalyzer: if sym_type not in DATA_SYMBOL_TYPES: continue + if (existing := symbols_by_addr.get(addr)) is not None: + # Prefer a global (uppercase type) name as the primary so + # nm output order can't hide it behind a local alias. + if sym_type.isupper() and existing.sym_type.islower(): + existing.aliases.append(existing.name) + existing.name = name + existing.sym_type = sym_type + else: + existing.aliases.append(name) + existing.size = max(existing.size, size) + continue + # Check if symbol is in a RAM section for section_name in self.ram_sections: if section_name not in self.sections: @@ -260,15 +278,15 @@ class RamStringsAnalyzer: section = self.sections[section_name] if section.address <= addr < section.address + section.size: - self.ram_symbols.append( - RamSymbol( - name=name, - sym_type=sym_type, - address=addr, - size=size, - section=section_name, - ) + symbol = RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, ) + symbols_by_addr[addr] = symbol + self.ram_symbols.append(symbol) break def _demangle_symbols(self) -> None: @@ -436,7 +454,13 @@ class RamStringsAnalyzer: for symbol in largest_symbols: # Use demangled name if available, otherwise raw name display_name = symbol.demangled or symbol.name - name_display = display_name[:49] if len(display_name) > 49 else display_name + # Truncate the name, not the alias note, so merged aliases stay + # visible even for long demangled C++ names. + alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else "" + max_name_len = 49 - len(alias_note) + if len(display_name) > max_name_len: + display_name = display_name[:max_name_len] + name_display = display_name + alias_note lines.append( f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" ) diff --git a/esphome/async_thread.py b/esphome/async_thread.py index c5225a7a14..3972d735f5 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -12,12 +12,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import threading -from typing import Generic, TypeVar - -_T = TypeVar("_T") -class AsyncThreadRunner(threading.Thread, Generic[_T]): +class AsyncThreadRunner[T](threading.Thread): """Run an async coroutine in a daemon thread and expose its result. The runner catches all exceptions from the coroutine and stores them in @@ -35,10 +32,10 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]): result = runner.result """ - def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: + def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: super().__init__(daemon=True) self._coro_factory = coro_factory - self.result: _T | None = None + self.result: T | None = None self.exception: BaseException | None = None self.event = threading.Event() diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9e11d785c0..cc2fc5c4cd 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -6,6 +6,11 @@ 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_cxx_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 @@ -84,17 +89,20 @@ 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 ) + # Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS + # (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as + # -Wno-volatile is passed on a C compile. + cxx_compile_options = "\n".join( + f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)' + for flag in get_project_cxx_compile_flags() + ) + cpp_standard_options = ( CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) if CORE.cpp_standard @@ -159,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) {cpp_standard_options} +{cxx_compile_options} + {extra_compile_options} {managed_components_property} @@ -188,8 +198,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 diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index a583279ea7..b63c4b733d 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -108,7 +108,6 @@ Import("env") def write_cxx_flags_script() -> None: path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME) contents = CXX_FLAGS_FILE_CONTENTS - if not CORE.is_host: - contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])' - contents += "\n" + for flag in sorted(CORE.cxx_build_flags): + contents += f'env.Append(CXXFLAGS=["{flag}"])\n' write_file_if_changed(path, contents) diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..56a47d146e 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cxx_build_flag, add_define, add_global, add_library, diff --git a/esphome/components/__init__.py b/esphome/components/__init__.py index e69de29bb2..3d7a546253 100644 --- a/esphome/components/__init__.py +++ b/esphome/components/__init__.py @@ -0,0 +1,6 @@ +# Importing `esphome.loader` here installs the component-alias +# ``sys.meta_path`` finder before any submodule lookup runs. Without this, +# `from esphome.components import ` from a fresh interpreter +# can race the finder install and raise ImportError, since the legacy +# alias dir no longer exists on disk. +from esphome import loader as _loader # noqa: F401 diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 344456854b..6111af2b7e 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() { if (this->buffer_[3] == checksum) { float distance = (this->buffer_[1] << 8) + this->buffer_[2]; if (distance > 280) { - float meters = distance / 1000.0; + float meters = distance / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters); this->publish_state(meters); } else { 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.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 3e21d6981d..477962a040 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -216,7 +216,7 @@ void AcDimmer::setup() { } void AcDimmer::write_state(float state) { - state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation + state = std::acos(1 - (2 * state)) / std::numbers::pi_v; // RMS power compensation auto new_value = static_cast(roundf(state * 65535)); if (new_value != 0 && this->store_.value == 0) this->store_.init_cycle = this->init_with_half_cycle_; 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/__init__.py b/esphome/components/adc/__init__.py index 96c8334a6d..555d511f6e 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { def validate_adc_pin(value): if str(value).upper() == "VCC": - if CORE.is_rp2040: + if CORE.is_rp2: return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") if str(value).upper() == "TEMPERATURE": - return cv.only_on_rp2040("TEMPERATURE") + return cv.only_on_rp2("TEMPERATURE") if CORE.is_esp32: conf = pins.internal_gpio_input_pin_schema(value) @@ -261,11 +261,11 @@ def validate_adc_pin(value): raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC") return conf - if CORE.is_rp2040: + if CORE.is_rp2: conf = pins.internal_gpio_input_pin_schema(value) number = conf[CONF_NUMBER] if number not in (26, 27, 28, 29): - raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC") + raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC") return conf if CORE.is_libretiny: diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 676940eca1..7131898747 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. @@ -123,9 +123,9 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage void set_autorange(bool autorange) { this->autorange_ = autorange; } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_is_temperature() { this->is_temperature_ = true; } -#endif // USE_RP2040 +#endif // USE_RP2 protected: uint8_t sample_count_{1}; @@ -152,9 +152,9 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage static adc_oneshot_unit_handle_t shared_adc_handles[2]; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 bool is_temperature_{false}; -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ZEPHYR const struct adc_dt_spec *channel_ = nullptr; diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2.cpp similarity index 97% rename from esphome/components/adc/adc_sensor_rp2040.cpp rename to esphome/components/adc/adc_sensor_rp2.cpp index 894c346588..6cb9ef113f 100644 --- a/esphome/components/adc/adc_sensor_rp2040.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "adc_sensor.h" #include "esphome/core/log.h" @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2040"; +static const char *const TAG = "adc.rp2"; void ADCSensor::setup() { static bool initialized = false; @@ -102,4 +102,4 @@ float ADCSensor::sample() { } // namespace esphome::adc -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 09e09f0dc1..86e2b771ab 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "adc_sensor_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, 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.cpp b/esphome/components/am43/cover/am43_cover.cpp index 35366dbaa6..4b096983a4 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -114,13 +114,13 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->decoder_->decode(param->notify.value, param->notify.value_len); if (this->decoder_->has_position()) { - this->position = ((float) this->decoder_->position_ / 100.0); + this->position = ((float) this->decoder_->position_ / 100.0f); if (!this->invert_position_) this->position = 1 - this->position; - if (this->position > 0.97) - this->position = 1.0; - if (this->position < 0.02) - this->position = 0.0; + if (this->position > 0.97f) + this->position = 1.0f; + if (this->position < 0.02f) + this->position = 0.0f; this->publish_state(); } 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/__init__.py b/esphome/components/animation/__init__.py index 9c9c7e3871..0df7c56313 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -1,114 +1,36 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after +# 2027.1.0. +# +# Animations are now a platform of the `image:` component (`platform: +# animation`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `animation:` key working during the +# deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components.const import CONF_LOOP import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_REPEAT -_LOGGER = logging.getLogger(__name__) +from .image import ANIMATION_CONFIG_SCHEMA, setup_animation -AUTO_LOAD = ["image"] +AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_START_FRAME = "start_frame" -CONF_END_FRAME = "end_frame" -CONF_FRAME = "frame" +DOMAIN = "animation" -animation_ns = cg.esphome_ns.namespace("animation") +LEGACY_REMOVAL_VERSION = "2027.1.0" -Animation_ = animation_ns.class_("Animation", espImage.Image_) - -# Actions -NextFrameAction = animation_ns.class_( - "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) -) -PrevFrameAction = animation_ns.class_( - "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) -) -SetFrameAction = animation_ns.class_( - "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +_capture_legacy_entry, _warn_legacy_animation = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -CONFIG_SCHEMA = cv.All( - espImage.IMAGE_SCHEMA.extend( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Optional(CONF_LOOP): cv.All( - { - cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, - cv.Optional(CONF_END_FRAME): cv.positive_int, - cv.Optional(CONF_REPEAT): cv.positive_int, - } - ), - }, - ), - espImage.validate_settings, -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_animation -NEXT_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -PREV_FRAME_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(Animation_), - } -) -SET_FRAME_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(Animation_), - cv.Required(CONF_FRAME): cv.uint16_t, - } -) - - -@automation.register_action( - "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True -) -@automation.register_action( - "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True -) -async def animation_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if (frame := config.get(CONF_FRAME)) is not None: - template_ = await cg.templatable(frame, args, cg.uint16) - cg.add(var.set_frame(template_)) - return var - - -async def to_code(config): - ( - prog_arr, - width, - height, - image_type, - trans_value, - frame_count, - ) = await espImage.write_image(config, all_frames=True) - - var = cg.new_Pvariable( - config[CONF_ID], - prog_arr, - width, - height, - frame_count, - image_type, - trans_value, - ) - if loop_config := config.get(CONF_LOOP): - start = loop_config[CONF_START_FRAME] - end = loop_config.get(CONF_END_FRAME, frame_count) - count = loop_config.get(CONF_REPEAT, -1) - cg.add(var.set_loop(start, end, count)) +to_code = setup_animation 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/animation/image.py b/esphome/components/animation/image.py new file mode 100644 index 0000000000..95875fe2b0 --- /dev/null +++ b/esphome/components/animation/image.py @@ -0,0 +1,115 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_LOOP +from esphome.components.file.image import image_schema, write_image +from esphome.components.image import Image_, validate_settings +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_REPEAT +from esphome.types import ConfigType + +CODEOWNERS = ["@syndlex"] +AUTO_LOAD = ["file"] +DEPENDENCIES = ["display"] + +CONF_START_FRAME = "start_frame" +CONF_END_FRAME = "end_frame" +CONF_FRAME = "frame" + +animation_ns = cg.esphome_ns.namespace("animation") + +Animation_ = animation_ns.class_("Animation", Image_) + +# Actions +NextFrameAction = animation_ns.class_( + "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_) +) +PrevFrameAction = animation_ns.class_( + "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_) +) +SetFrameAction = animation_ns.class_( + "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_) +) + +ANIMATION_SCHEMA = image_schema(Animation_).extend( + { + cv.Optional(CONF_LOOP): cv.All( + { + cv.Optional(CONF_START_FRAME, default=0): cv.positive_int, + cv.Optional(CONF_END_FRAME): cv.positive_int, + cv.Optional(CONF_REPEAT): cv.positive_int, + } + ), + }, +) + +# Shared schema used by both the (deprecated) top-level `animation:` key and the +# `image:` `platform: animation` entry. +ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings) + + +NEXT_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +PREV_FRAME_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(Animation_), + } +) +SET_FRAME_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(Animation_), + cv.Required(CONF_FRAME): cv.uint16_t, + } +) + + +@automation.register_action( + "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True +) +@automation.register_action( + "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True +) +async def animation_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if (frame := config.get(CONF_FRAME)) is not None: + template_ = await cg.templatable(frame, args, cg.uint16) + cg.add(var.set_frame(template_)) + return var + + +async def setup_animation(config: ConfigType) -> None: + ( + prog_arr, + width, + height, + image_type, + trans_value, + frame_count, + ) = await write_image(config, all_frames=True) + + var = cg.new_Pvariable( + config[CONF_ID], + prog_arr, + width, + height, + frame_count, + image_type, + trans_value, + ) + if loop_config := config.get(CONF_LOOP): + start = loop_config[CONF_START_FRAME] + end = loop_config.get(CONF_END_FRAME, frame_count) + count = loop_config.get(CONF_REPEAT, -1) + cg.add(var.set_loop(start, end, count)) + + +CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA + +to_code = setup_animation 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/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp index 84dd4393eb..806a441dcd 100644 --- a/esphome/components/anova/anova_base.cpp +++ b/esphome/components/anova/anova_base.cpp @@ -6,9 +6,9 @@ namespace esphome::anova { -float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); } +float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); } -float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; } +float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; } AnovaPacket *AnovaCodec::clean_packet_() { this->packet_.length = strlen((char *) this->packet_.data); 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/__init__.py b/esphome/components/api/__init__.py index 932702d47a..0719cee352 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register the API as a provisioning source when encryption is enabled. + + With no ``key`` the device boots unprovisioned and is set up on first + connection; a YAML ``key`` means it is born provisioned. Either way the API + drives the provisioning manager, so it counts as a source for `provisioning:`. + A hardcoded ``key`` is reported so `provisioning:` can warn about it. + """ + if (encryption := config.get(CONF_ENCRYPTION)) is not None: + from esphome.components import provisioning + + provisioning.register_source("api") + if CONF_KEY in encryption: + provisioning.report_hardcoded_credentials("api") + return config + + def validate_encryption_key(value): value = cv.string_strict(value) try: @@ -300,21 +317,23 @@ CONFIG_SCHEMA = cv.All( CONF_LISTEN_BACKLOG, esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets esp32=4, # More RAM (520KB), BSD sockets - rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 bk72xx=4, # Moderate RAM, BSD-style sockets rtl87xx=4, # Moderate RAM, BSD-style sockets host=4, # Abundant resources ln882x=4, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets ): cv.int_range(min=1, max=10), cv.SplitDefault( CONF_MAX_CONNECTIONS, esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes esp32=5, # 520KB RAM available - rp2040=4, # 264KB RAM but LWIP constraints + rp2=4, # 264KB RAM but LWIP constraints bk72xx=5, # Moderate RAM rtl87xx=5, # Moderate RAM host=8, # Abundant resources ln882x=5, # Moderate RAM + nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller) ): cv.int_range(min=1, max=20), # Maximum queued send buffers per connection before dropping connection # Each buffer uses ~8-12 bytes overhead plus actual message size @@ -324,7 +343,7 @@ CONFIG_SCHEMA = cv.All( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast esp32=8, # More RAM, can buffer more - rp2040=8, # Moderate RAM + rp2=8, # Moderate RAM bk72xx=8, # Moderate RAM nrf52=8, # Moderate RAM rtl87xx=8, # Moderate RAM @@ -335,6 +354,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), _consume_api_sockets, + _register_provisioning_source, ) @@ -468,8 +488,11 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired - # This will allow a plaintext client to provide a noise key, - # send it to the device, and then switch to noise. + # Until a key is set, the device accepts both Noise connections + # using the well-known all-zeros PSK (preferred: the key travels + # encrypted, protecting against passive sniffing) and plaintext + # connections (deprecated, remove after 2027.2.0) so a client can + # provide a noise key and the device then switches to noise only. # The key will be saved in flash and used for future connections # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") @@ -538,17 +561,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +# synchronous=False: when on_success/on_error is configured, play() stores the +# trigger args until the HomeassistantActionResponse arrives, so non-owning args +# (StringRef into the API receive buffer) must not be used. @automation.register_action( "homeassistant.action", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) @automation.register_action( "homeassistant.service", HomeAssistantServiceCallAction, HOMEASSISTANT_ACTION_ACTION_SCHEMA, - synchronous=True, + synchronous=False, ) async def homeassistant_service_to_code( config: ConfigType, @@ -642,6 +668,8 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( ) +# synchronous=True is safe here: the event schema has no on_success/on_error, +# so play() never stores the trigger args. @automation.register_action( "homeassistant.event", HomeAssistantServiceCallAction, diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f4f15c1042..4b3df62ec4 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -158,6 +158,16 @@ message AuthenticationResponse { bool invalid_password = 1; } +// Reason a party is requesting the connection be closed. +enum DisconnectReason { + // No specific reason / not provided (default for older peers). + DISCONNECT_REASON_UNSPECIFIED = 0; + // The device's provisioning window has expired. The device must be reset + // (power-cycled) to reopen the provisioning window before it will accept a + // connection again. + DISCONNECT_REASON_PROVISIONING_CLOSED = 1; +} + // Request to close the connection. // Can be sent by both the client and server message DisconnectRequest { @@ -166,6 +176,10 @@ message DisconnectRequest { option (no_delay) = true; // Do not close the connection before the acknowledgement arrives + + // Optional reason the connection is being closed. Older peers that do not + // send this field will report DISCONNECT_REASON_UNSPECIFIED (0). + DisconnectReason reason = 1; } message DisconnectResponse { @@ -296,6 +310,11 @@ message DeviceInfoResponse { // Serial proxy instance metadata repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; + + // Device is unprovisioned and accepts Noise handshakes with the well-known + // all-zeros PSK, so the api encryption key can be provisioned without being + // sent in plaintext (protects against passive sniffing, not active MITM) + bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 2b1458e2ae..880b7cc404 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -25,6 +25,9 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/version.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_DEEP_SLEEP #include "esphome/components/deep_sleep/deep_sleep_component.h" @@ -195,6 +198,29 @@ APIConnection::~APIConnection() { #endif } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) +void APIConnection::upgrade_helper_to_noise_() { + // The client opened with a Noise hello while this device has no encryption + // key set. Replace the plaintext helper with a Noise helper so the key can + // be provisioned over an encrypted channel: the noise context PSK is all + // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519 + // exchange, so a passive listener cannot read the session. A publicly known + // PSK authenticates nobody; this protects against sniffing only. + auto *plaintext = static_cast(this->helper_.get()); + uint8_t header[3]; + uint8_t header_len = plaintext->get_consumed_header(header); + auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx()); + // Carry over the peername-based client name (Hello has not arrived yet) + const char *name = plaintext->get_client_name(); + noise->set_client_name(name, strlen(name)); + this->helper_.reset(noise); // destroys the plaintext helper + APIError err = noise->init_from_handoff(header, header_len); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err); + } +} +#endif // USE_API_NOISE && USE_API_PLAINTEXT + void APIConnection::destroy_active_iterator_() { switch (this->active_iterator_) { case ActiveIterator::LIST_ENTITIES: @@ -253,6 +279,15 @@ void APIConnection::loop() { // No more data available break; } else if (err != APIError::OK) { +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Checked inside the error branch to keep the hot err == OK path + // free of it; this can only fire on the first bytes of a plaintext + // helper on an unprovisioned device + if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) { + this->upgrade_helper_to_noise_(); + return; + } +#endif this->fatal_error_with_log_(LOG_STR("Reading failed"), err); return; } else { @@ -375,7 +410,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 +453,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(); } @@ -1358,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet #ifdef USE_ZWAVE_PROXY void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { - zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len); + zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len); } void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { @@ -1734,6 +1759,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + // The provisioning window has closed without the device being provisioned. + // Acknowledge the hello so the client can read the server name, then request + // disconnect with the reason. Authentication is intentionally not completed. + this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); + this->send_message(resp); + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + return this->send_message(req); + } +#endif + // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); @@ -1769,7 +1807,7 @@ bool APIConnection::send_device_info_response_() { // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) #define ESPHOME_MANUFACTURER "Espressif" -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) #define ESPHOME_MANUFACTURER "Beken" @@ -1854,6 +1892,12 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_API_NOISE resp.api_encryption_supported = true; +#ifndef USE_API_NOISE_PSK_FROM_YAML + // No key from YAML: while no key is set, the key can be provisioned over a + // zero-PSK Noise connection. Gated on the YAML define (not the plaintext + // one) so this advertisement survives the plaintext removal in 2027.2.0. + resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; @@ -1884,7 +1928,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) { this->on_fatal_error(); } } -void APIConnection::on_disconnect_request() { +void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) { + // The reason is informational when a client disconnects us; we always ack and close. if (!this->send_disconnect_response_()) { this->on_fatal_error(); } @@ -2012,6 +2057,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio NoiseEncryptionSetKeyResponse resp; resp.success = false; +#ifdef USE_PROVISIONING + // Refuse to set a key once the provisioning window has closed (defense in depth; + // such connections are already rejected at hello). + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning closed; rejecting key set"); + return this->send_message(resp); + } +#endif + psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { @@ -2021,10 +2075,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); + } else if (APINoiseContext::is_all_zeros(psk)) { + // Accepting the reserved provisioning PSK would report success without + // enabling encryption (or silently clear an existing key) + ESP_LOGW(TAG, "Rejecting all-zero encryption key"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); } else { resp.success = true; +#ifdef USE_API_PLAINTEXT + if (this->helper_->frame_footer_size() == 0) { + // Plaintext transport has no frame footer; Noise always has the MAC footer. + // Remove after 2027.2.0 together with plaintext support on keyless devices. + ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0"); + } +#endif } return this->send_message(resp); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 804cd9ddd1..7df7ea1429 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -18,8 +18,8 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "esphome/components/esp32/crash_handler.h" #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif #ifdef USE_ESP8266_CRASH_HANDLER #include "esphome/components/esp8266/crash_handler.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"); @@ -169,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase { #endif bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len); #ifdef USE_API_HOMEASSISTANT_SERVICES - void send_homeassistant_action(const HomeassistantActionRequest &call) { + // Returns whether this client has subscribed to Home Assistant actions; the message + // is only handed to the send path when subscribed. A true return does not guarantee + // delivery - it lets the caller warn when no connected client has the subscription. + bool send_homeassistant_action(const HomeassistantActionRequest &call) { if (!this->flags_.service_call_subscription) - return; + return false; this->send_message(call); + return true; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); @@ -262,7 +263,7 @@ class APIConnection final : public APIServerConnectionBase { void on_get_time_response(const GetTimeResponse &value); #endif void on_hello_request(const HelloRequest &msg); - void on_disconnect_request(); + void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } @@ -282,8 +283,8 @@ class APIConnection final : public APIServerConnectionBase { esp32::crash_handler_log(); esp32::crash_handler_clear(); #endif -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_log(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_log(); #endif #ifdef USE_ESP8266_CRASH_HANDLER esp8266::crash_handler_log(); @@ -481,13 +482,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); @@ -636,6 +630,11 @@ class APIConnection final : public APIServerConnectionBase { void destroy_active_iterator_(); void begin_iterator_(ActiveIterator type); void finalize_iterator_sync_(); +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Swap the plaintext helper for a Noise helper after the client opened + // with a Noise hello on an unprovisioned device (zero-PSK provisioning). + void upgrade_helper_to_noise_(); +#endif #ifdef USE_CAMERA std::unique_ptr image_reader_; #endif diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 90353b6402..7425304766 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) { return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE"); } #endif + // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before + // any logging can happen, so it intentionally has no entry here. return LOG_STR("UNKNOWN"); } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98eca8076..9cae6ba92e 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -88,6 +88,11 @@ enum class APIError : uint16_t { HANDSHAKESTATE_SPLIT_FAILED = 1020, BAD_HANDSHAKE_ERROR_BYTE = 1021, #endif +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Not an error: an unprovisioned device received a Noise client hello on a + // plaintext connection; the caller must hand the socket off to a Noise helper. + PROTOCOL_SWITCH_TO_NOISE = 1023, +#endif }; const LogString *api_error_to_logstr(APIError err); @@ -200,6 +205,12 @@ class APIFrameHelper { // or track that they stopped early and retry without this check. // See Socket::ready() for details. bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } +#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) + // Move the socket out of this helper so a replacement helper can take it + // over (plaintext to Noise handoff on unprovisioned devices). The drained + // helper must be destroyed right after. + std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); } +#endif // Release excess memory from internal buffers after initial sync void release_buffers() { // rx_buf_: Safe to clear only if no partial read in progress. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 6dba64a7f8..225bac51a6 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_PLAINTEXT +APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { + APIError err = this->init(); + if (err != APIError::OK) { + return err; + } + // Seed the header bytes the plaintext helper consumed before detecting the + // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_. + std::memcpy(this->rx_header_buf_, header, header_len); + this->rx_header_buf_len_ = header_len; + // Pump the handshake without gating on socket_->ready(): on LWIP the + // plaintext helper's partial read can drain rcvevent while the rest of the + // client hello sits in the lastdata cache, so ready() may report false even + // though data is available. + return this->pump_handshake_(); +} +#endif // USE_API_PLAINTEXT + +/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal +/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK +/// and resume on the next loop(). +APIError APINoiseFrameHelper::pump_handshake_() { + while (this->state_ != State::DATA) { + APIError err = this->state_action_(); + if (err == APIError::WOULD_BLOCK) { + break; + } + if (err != APIError::OK) { + return err; + } + } + return APIError::OK; +} + // Helper for handling handshake frame errors APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) { if (aerr == APIError::BAD_INDICATOR) { @@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func /// Run through handshake messages (if in that phase) APIError APINoiseFrameHelper::loop() { - // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once - // the rx buffer is consumed. Re-checking each iteration would block handshake writes - // that must follow reads, deadlocking the handshake. state_action() will return - // WOULD_BLOCK when no more data is available to read. - bool socket_ready = this->socket_->ready(); - while (state_ != State::DATA && socket_ready) { - APIError err = state_action_(); - if (err == APIError::WOULD_BLOCK) { - break; - } + // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP, + // ready() returns false once the rx buffer is consumed. Re-checking each + // iteration would block handshake writes that must follow reads, + // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when + // no more data is available to read. + if (state_ != State::DATA && this->socket_->ready()) { + APIError err = this->pump_handshake_(); if (err != APIError::OK) { return err; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 0676eab78d..b0ba9fd01c 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper { } ~APINoiseFrameHelper() override; APIError init() override; +#ifdef USE_API_PLAINTEXT + // Take over a connection whose first bytes were consumed by a plaintext + // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE). + // Seeds the already-read header bytes and pumps the handshake state machine + // until it would block. + APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: + APIError pump_handshake_(); APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fa611a6e33..9359f568fb 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // If this was the first read, validate the indicator byte if (rx_header_buf_pos_ == 0 && received > 0) { if (rx_header_buf_[0] != 0x00) { +#ifdef USE_API_NOISE + // Dual build (encryption supported but no key set): a 0x01 first byte + // is a Noise client hello. Hand the connection off to a Noise helper + // running the all-zeros provisioning PSK so the encryption key can be + // set without crossing the wire in plaintext. Preserve the bytes we + // already consumed; they are the start of the Noise 3-byte header. + if (rx_header_buf_[0] == 0x01) { + rx_header_buf_pos_ = static_cast(received); + return APIError::PROTOCOL_SWITCH_TO_NOISE; + } +#endif state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 8314754715..ea3f6d7280 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError read_packet(ReadPacketBuffer *buffer) override; APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; +#ifdef USE_API_NOISE + // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the + // header bytes already consumed from the socket (at most 3, the size of the + // Noise fixed header) so the replacement Noise helper can be seeded with them. + uint8_t get_consumed_header(uint8_t out[3]) const { + memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_); + return this->rx_header_buf_pos_; + } +#endif protected: APIError try_read_frame_(); diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h index b5f7016689..44484ffa2c 100644 --- a/esphome/components/api/api_noise_context.h +++ b/esphome/components/api/api_noise_context.h @@ -10,13 +10,20 @@ using psk_t = std::array; class APINoiseContext { public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } void set_psk(psk_t psk) { this->psk_ = psk; - bool has_psk = false; - for (auto i : psk) { - has_psk |= i; - } - this->has_psk_ = has_psk; + this->has_psk_ = !is_all_zeros(psk); } const psk_t &get_psk() const { return this->psk_; } bool has_psk() const { return this->has_psk_; } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c711ef167c..190bd32425 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const { size += 2 + this->name.size(); return size; } +bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->reason = static_cast(value); + break; + default: + return false; + } + return true; +} +uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); + return pos; +} +uint32_t DisconnectRequest::calculate_size() const { + uint32_t size = 0; + size += this->reason ? 2 : 0; + return size; +} #ifdef USE_AREAS uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); @@ -150,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ for (const auto &it : this->serial_proxies) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); } +#endif +#ifdef USE_API_NOISE + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); #endif return pos; } @@ -212,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const { for (const auto &it : this->serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } +#endif +#ifdef USE_API_NOISE + size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e926ee0d4..4d5866da0b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -11,6 +11,10 @@ namespace esphome::api { namespace enums { +enum DisconnectReason : uint32_t { + DISCONNECT_REASON_UNSPECIFIED = 0, + DISCONNECT_REASON_PROVISIONING_CLOSED = 1, +}; enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, @@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage { protected: }; -class DisconnectRequest final : public ProtoMessage { +class DisconnectRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 5; - static constexpr uint8_t ESTIMATED_SIZE = 0; + static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif + enums::DisconnectReason reason{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: + bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; class DisconnectResponse final : public ProtoMessage { public: @@ -525,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 309; + static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -580,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; +#endif +#ifdef USE_API_NOISE + bool api_encryption_provisionable{false}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 850ad37bc9..09570b09e4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint } #pragma GCC diagnostic pop +template<> const char *proto_enum_to_string(enums::DisconnectReason value) { + switch (value) { + case enums::DISCONNECT_REASON_UNSPECIFIED: + return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED"); + case enums::DISCONNECT_REASON_PROVISIONING_CLOSED: + return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) { switch (value) { case enums::SERIAL_PROXY_PORT_TYPE_TTL: @@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } const char *DisconnectRequest::dump_to(DumpBuffer &out) const { - out.append_p(ESPHOME_PSTR("DisconnectRequest {}")); + MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest")); + dump_field(out, ESPHOME_PSTR("reason"), static_cast(this->reason)); return out.c_str(); } const char *DisconnectResponse::dump_to(DumpBuffer &out) const { @@ -971,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); out.append("\n"); } +#endif +#ifdef USE_API_NOISE + dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); #endif return out.c_str(); } diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index f45e091c6f..70ba579fcc 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -31,6 +31,13 @@ #include #include +#if defined(LOG_LEVEL_NONE) +// Zephyr defines LOG_LEVEL_NONE as a logging macro that collides with the LogLevel enum value of +// the same name in the generated api_pb2.h. Undefine it for the rest of this translation unit so +// the enum parses; nothing below needs Zephyr's logging macro. +#undef LOG_LEVEL_NONE +#endif + namespace esphome::api { // This file only provides includes, no actual code diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 0ba2961a13..5c9df433dd 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } case DisconnectRequest::MESSAGE_TYPE: { + DisconnectRequest msg; + msg.decode(msg_data, msg_size); #ifdef HAS_PROTO_MESSAGE_DUMP - this->log_receive_message_(LOG_STR("on_disconnect_request")); + this->log_receive_message_(LOG_STR("on_disconnect_request"), msg); #endif - this->on_disconnect_request(); + this->on_disconnect_request(msg); break; } case DisconnectResponse::MESSAGE_TYPE: { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index aca42ca303..d1b51f4846 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -21,7 +21,7 @@ class APIServerConnectionBase { void on_hello_request(const HelloRequest &value){}; - void on_disconnect_request(){}; + void on_disconnect_request(const DisconnectRequest &value){}; void on_disconnect_response(){}; void on_ping_request(){}; void on_ping_response(){}; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index ddd03ace4a..6e3448121c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -107,8 +107,30 @@ void APIServer::setup() { // Initialize last_connected_ for reboot timeout tracking this->last_connected_ = App.get_loop_component_start_time(); - // Set warning status if reboot timeout is enabled - if (this->reboot_timeout_ != 0) { +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Register with the provisioning manager (provisioning:) as a source and + // report our current state (provisioned == an encryption key is set). When the + // window closes, disconnect any client still attempting to provision so it learns + // the reason. The manager owns the timeout, window state and on_timeout automation. + if (provisioning::global_provisioning_manager != nullptr) { + this->provisioning_source_ = provisioning::global_provisioning_manager->register_source(); + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, + this->noise_ctx_.has_psk()); + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + for (auto &c : this->active_clients()) { + DisconnectRequest req; + req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; + // Best-effort: if the send buffer is full the reason is dropped, but the + // client still learns the window is closed when it reconnects (rejected at + // hello) or via the socket close. + c->send_message(req); + } + }); + } +#endif + // Set warning status if reboot timeout is enabled (suppressed while provisioning + // is pending so the device waits to be onboarded instead of rebooting). + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); } } @@ -121,8 +143,10 @@ void APIServer::loop() { if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn - // (cancelled scheduler items sit in heap memory until their scheduled time) - if (this->reboot_timeout_ != 0) { + // (cancelled scheduler items sit in heap memory until their scheduled time). + // Suppressed while a provisioning window is pending so the device waits to be + // onboarded / reset instead of rebooting itself; resumes once provisioned. + if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_connected_ > this->reboot_timeout_) { ESP_LOGE(TAG, "No clients; rebooting"); @@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) { this->clients_[last_index].reset(); // Last client disconnected - set warning and start tracking for reboot timeout - if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) { + // (suppressed while provisioning is pending - see loop()). + if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); this->last_connected_ = App.get_loop_component_start_time(); } @@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { conn->start(); // First client connected - clear warning and update timestamp - if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) { + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_clear_warning(); this->last_connected_ = App.get_loop_component_start_time(); } @@ -240,12 +265,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { } void APIServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Server:\n" " Address: %s:%u\n" " Listen backlog: %u\n" " Max connections: %u", - network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); + network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); if (!this->noise_ctx_.has_psk()) { @@ -400,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat #ifdef USE_API_HOMEASSISTANT_SERVICES void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) { + bool has_subscriber = false; for (auto &client : this->active_clients()) { - client->send_homeassistant_action(call); + has_subscriber |= client->send_homeassistant_action(call); + } + if (!has_subscriber) { + // Home Assistant subscribes to actions shortly *after* authenticating, so actions + // fired right at connection time (on_client_connected, on_time_sync, ...) can + // arrive before the subscription and are lost - warn instead of failing silently. + ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(), + this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected"); } } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -571,8 +605,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { } SavedNoisePsk new_saved_psk{psk}; - return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), - make_active); + bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The device now has a key; report provisioned so the provisioning window is + // satisfied and the reboot timeout resumes normal operation. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true); + } +#endif + return result; #endif } bool APIServer::clear_noise_psk(bool make_active) { @@ -583,8 +625,16 @@ bool APIServer::clear_noise_psk(bool make_active) { return false; #else SavedNoisePsk empty_psk{}; - return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), - make_active); + bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), + make_active); +#ifdef USE_PROVISIONING + // The key was cleared; report unprovisioned so a subsequent reboot reopens the + // provisioning window. + if (result && provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false); + } +#endif + return result; #endif } #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fbc8115091..248b83a0ff 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -14,6 +14,9 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -255,6 +258,19 @@ class APIServer final : public Component, // Remove a disconnected client by index. Swaps with the last populated slot and resets it. void __attribute__((noinline)) remove_client_(uint8_t client_index); +#ifdef USE_PROVISIONING + // True while a configured provisioning window is still pending (the device is + // unprovisioned). Suppresses the reboot timeout and its warning so the device is + // not auto-rebooted while waiting to be provisioned. False when no provisioning + // window is configured. + bool provisioning_pending_() const { + return provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); + } +#else + bool provisioning_pending_() const { return false; } +#endif + #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active); @@ -332,7 +348,10 @@ class APIServer final : public Component, uint8_t listen_backlog_{4}; bool shutting_down_ = false; uint8_t api_connection_count_{0}; - // 7 bytes used, 1 byte padding +#if defined(USE_PROVISIONING) && defined(USE_API_NOISE) + // Index assigned by the provisioning manager for reporting this transport's state. + uint8_t provisioning_source_{0}; +#endif #ifdef USE_API_NOISE APINoiseContext noise_ctx_; @@ -342,7 +361,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/as7341/sensor.py b/esphome/components/as7341/sensor.py index fa51a1cdfa..8b6cf61028 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,7 +5,7 @@ from esphome.const import ( CONF_CLEAR, CONF_GAIN, CONF_ID, - DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) @@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 2a07903b68..22d544ba37 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]: if ( not CORE.is_esp32 and not CORE.is_esp8266 - and not CORE.is_rp2040 + and not CORE.is_rp2 and not CORE.is_libretiny ): return ["socket"] @@ -37,7 +37,7 @@ async def to_code(config): elif CORE.is_esp8266: # https://github.com/ESP32Async/ESPAsyncTCP cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0") - elif CORE.is_rp2040: + elif CORE.is_rp2: # https://github.com/ayushsharma82/RPAsyncTCP # RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better # ESPAsyncWebServer compatibility @@ -47,6 +47,6 @@ async def to_code(config): def FILTER_SOURCE_FILES() -> list[str]: # Exclude socket implementation for platforms that use AsyncTCP libraries - if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny: + if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny: return ["async_tcp_socket.cpp"] return [] diff --git a/esphome/components/async_tcp/async_tcp.h b/esphome/components/async_tcp/async_tcp.h index 21fcfe239f..0906a07844 100644 --- a/esphome/components/async_tcp/async_tcp.h +++ b/esphome/components/async_tcp/async_tcp.h @@ -7,7 +7,7 @@ #elif defined(USE_ESP8266) // Use ESPAsyncTCP library for ESP8266 (always Arduino) #include -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Use RPAsyncTCP library for RP2040 #include #else diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index e8c0f163b3..10cbc981c7 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -1,6 +1,6 @@ #include "async_tcp_socket.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/network/util.h" diff --git a/esphome/components/async_tcp/async_tcp_socket.h b/esphome/components/async_tcp/async_tcp_socket.h index 28714a7752..3b17fe14df 100644 --- a/esphome/components/async_tcp/async_tcp_socket.h +++ b/esphome/components/async_tcp/async_tcp_socket.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ (defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)) #include "esphome/components/socket/socket.h" 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.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index f8bbd9d55e..7b5cdcfa20 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -65,12 +65,11 @@ optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::S return {}; } - static uint8_t last_frame_count = 0; - if (last_frame_count == raw[12]) { - ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count); + if (this->last_frame_count_ == raw[12]) { + ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_); return {}; } - last_frame_count = raw[12]; + this->last_frame_count_ = raw[12]; return result; } diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc1..0f472c11b9 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; }; @@ -38,6 +38,8 @@ class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDevice sensor::Sensor *battery_voltage_{nullptr}; sensor::Sensor *signal_strength_{nullptr}; + uint8_t last_frame_count_{0}; + optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *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 2aceff0c97..d87f32fc36 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.3") + add_idf_component(name="esphome/micro-mp3", ref="0.4.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/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index f709c23fb6..fe9ad9c9ad 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -326,14 +326,8 @@ FileDecoderState AudioDecoder::decode_mp3_() { } else if (result == micro_mp3::MP3_NEED_MORE_DATA) { return FileDecoderState::MORE_TO_PROCESS; } else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) { - // Reallocate to decode the frame on the next call - if (this->mp3_decoder_->get_channels() > 0) { - this->free_buffer_required_ = - this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t); - } else { - // Fallback to worst-case size if channel info isn't available - this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); - } + // Fallback to worst-case size + this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes(); if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { return FileDecoderState::FAILED; } 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/__init__.py b/esphome/components/audio_file/__init__.py index 53193c8008..d59ed7411a 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] if file_type == "wav": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"] - elif file_type in ("mp3", "mpeg", "mpga"): + elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"): + # With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2". + # Treat those labels as MP3 so we still pick the MP3 decoder. media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"] elif file_type == "flac": media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"] 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/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index c719599b99..1d5ac6e702 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -8,7 +8,7 @@ namespace esphome::b_parasite { -class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); 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/__init__.py b/esphome/components/binary_sensor/__init__.py index a9a09363fc..5800e0bd9e 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Exclusive( CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE ): cv.boolean, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), 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.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 316d44ba59..3185f15697 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -112,7 +112,7 @@ float BinarySensorMap::bayesian_predicate_(bool sensor_state, float prior, float prob_state_source_false = 1 - prob_given_false; } - return prob_state_source_true / (prior * prob_state_source_true + (1.0 - prior) * prob_state_source_false); + return prob_state_source_true / (prior * prob_state_source_true + (1.0f - prior) * prob_state_source_false); } void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float value) { 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/bk72xx/boards.py b/esphome/components/bk72xx/boards.py index f8bedce329..6054b03f78 100644 --- a/esphome/components/bk72xx/boards.py +++ b/esphome/components/bk72xx/boards.py @@ -21,38 +21,6 @@ from esphome.components.libretiny.const import ( ) BK72XX_BOARDS = { - "wb2l-m1": { - "name": "WB2L_M1 Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "xh-wb3s": { - "name": "NiceMCU XH-WB3S", - "family": FAMILY_BK7238, - }, - "cbu": { - "name": "CBU Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "t1-u": { - "name": "T1-U Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7238-tuya": { - "name": "Generic - BK7238 (Tuya T1)", - "family": FAMILY_BK7238, - }, - "t1-m": { - "name": "T1-M Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "generic-bk7231t-qfn32-tuya": { - "name": "Generic - BK7231T (Tuya)", - "family": FAMILY_BK7231T, - }, - "generic-bk7231n-qfn32-tuya": { - "name": "Generic - BK7231N (Tuya)", - "family": FAMILY_BK7231N, - }, "cb1s": { "name": "CB1S Wi-Fi Module", "family": FAMILY_BK7231N, @@ -61,623 +29,117 @@ BK72XX_BOARDS = { "name": "CB2L Wi-Fi Module", "family": FAMILY_BK7231N, }, - "cblc5": { - "name": "CBLC5 Wi-Fi Module", + "cb2s": { + "name": "CB2S Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "cb3l": { + "name": "CB3L Wi-Fi Module", "family": FAMILY_BK7231N, }, "cb3s": { "name": "CB3S Wi-Fi Module", "family": FAMILY_BK7231N, }, - "wb3s": { - "name": "WB3S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "lsc-lma35": { - "name": "LSC LMA35 BK7231N", + "cb3se": { + "name": "CB3SE Wi-Fi Module", "family": FAMILY_BK7231N, }, - "generic-bk7252": { - "name": "Generic - BK7252", - "family": FAMILY_BK7251, - }, - "t1-3s": { - "name": "T1-3S Wi-Fi Module", - "family": FAMILY_BK7238, - }, - "wb2l": { - "name": "WB2L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wb1s": { - "name": "WB1S Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "wblc5": { - "name": "WBLC5 Wi-Fi Module", - "family": FAMILY_BK7231T, - }, - "cb2s": { - "name": "CB2S Wi-Fi Module", + "cblc5": { + "name": "CBLC5 Wi-Fi Module", "family": FAMILY_BK7231N, }, + "cbu": { + "name": "CBU Wi-Fi Module", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32": { + "name": "Generic - BK7231N", + "family": FAMILY_BK7231N, + }, + "generic-bk7231n-qfn32-tuya": { + "name": "Generic - BK7231N (Tuya)", + "family": FAMILY_BK7231N, + }, + "generic-bk7231t-qfn32-tuya": { + "name": "Generic - BK7231T (Tuya)", + "family": FAMILY_BK7231T, + }, "generic-bk7238": { "name": "Generic - BK7238", "family": FAMILY_BK7238, }, - "wa2": { - "name": "WA2 Wi-Fi Module", - "family": FAMILY_BK7231Q, + "generic-bk7238-tuya": { + "name": "Generic - BK7238 (Tuya T1)", + "family": FAMILY_BK7238, }, - "cb3l": { - "name": "CB3L Wi-Fi Module", + "generic-bk7252": { + "name": "Generic - BK7252", + "family": FAMILY_BK7251, + }, + "lsc-lma35": { + "name": "LSC LMA35 BK7231N", "family": FAMILY_BK7231N, }, "lsc-lma35-t": { "name": "LSC LMA35 BK7231T", "family": FAMILY_BK7231T, }, - "cb3se": { - "name": "CB3SE Wi-Fi Module", - "family": FAMILY_BK7231N, - }, - "wb3l": { - "name": "WB3L Wi-Fi Module", - "family": FAMILY_BK7231T, - }, "t1-2s": { "name": "T1-2S Wi-Fi Module", "family": FAMILY_BK7238, }, + "t1-3s": { + "name": "T1-3S Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-m": { + "name": "T1-M Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "t1-u": { + "name": "T1-U Wi-Fi Module", + "family": FAMILY_BK7238, + }, + "wa2": { + "name": "WA2 Wi-Fi Module", + "family": FAMILY_BK7231Q, + }, + "wb1s": { + "name": "WB1S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l": { + "name": "WB2L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb2l-m1": { + "name": "WB2L_M1 Wi-Fi Module", + "family": FAMILY_BK7231N, + }, "wb2s": { "name": "WB2S Wi-Fi Module", "family": FAMILY_BK7231T, }, + "wb3l": { + "name": "WB3L Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wb3s": { + "name": "WB3S Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "wblc5": { + "name": "WBLC5 Wi-Fi Module", + "family": FAMILY_BK7231T, + }, + "xh-wb3s": { + "name": "NiceMCU XH-WB3S", + "family": FAMILY_BK7238, + }, } BK72XX_BOARD_PINS = { - "wb2l-m1": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 26, - "D4": 24, - "D5": 10, - "D6": 11, - "D7": 1, - "D8": 0, - "D9": 20, - "D10": 21, - "D11": 23, - "D12": 22, - "A0": 23, - }, - "xh-wb3s": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 7, - "D1": 23, - "D2": 14, - "D3": 26, - "D4": 24, - "D5": 6, - "D6": 9, - "D7": 0, - "D8": 1, - "D9": 8, - "D10": 10, - "D11": 11, - "D12": 16, - "D13": 20, - "D14": 21, - "D15": 22, - "D16": 15, - "D17": 17, - "A0": 28, - "A1": 26, - "A2": 24, - "A3": 1, - "A4": 10, - "A5": 20, - }, - "cbu": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 20, - "D3": 22, - "D4": 23, - "D5": 1, - "D6": 0, - "D7": 8, - "D8": 7, - "D9": 6, - "D10": 26, - "D11": 24, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 9, - "D16": 17, - "D17": 15, - "D18": 21, - "A0": 23, - }, - "t1-u": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 14, - "D1": 16, - "D2": 23, - "D3": 22, - "D4": 20, - "D5": 1, - "D6": 0, - "D7": 24, - "D8": 9, - "D9": 26, - "D10": 6, - "D11": 8, - "D12": 11, - "D13": 10, - "D14": 28, - "D15": 21, - "D16": 17, - "D17": 15, - "A0": 20, - "A1": 1, - "A2": 24, - "A3": 26, - "A4": 10, - "A5": 28, - }, - "generic-bk7238-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "t1-m": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, - "generic-bk7231t-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 23, - }, - "generic-bk7231n-qfn32-tuya": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "P28": 28, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 23, - }, "cb1s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -765,22 +227,28 @@ BK72XX_BOARD_PINS = { "D7": 11, "D8": 21, }, - "cblc5": { + "cb2s": { "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P21": 21, + "P23": 23, "P24": 24, "P26": 26, "PWM0": 6, + "PWM1": 7, + "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, @@ -790,14 +258,61 @@ BK72XX_BOARD_PINS = { "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 11, + "D0": 6, + "D1": 7, + "D2": 8, + "D3": 23, "D4": 10, - "D5": 1, + "D5": 11, + "D6": 24, + "D7": 26, + "D8": 0, + "D9": 1, + "D10": 21, + "A0": 23, + }, + "cb3l": { + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P21": 21, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 9, "D6": 0, "D7": 21, + "D8": 8, + "D9": 7, + "D10": 10, + "D11": 11, + "A0": 23, }, "cb3s": { "WIRE1_SCL": 20, @@ -849,9 +364,11 @@ BK72XX_BOARD_PINS = { "D13": 20, "A0": 23, }, - "wb3s": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, + "cb3se": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -859,6 +376,9 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, "P0": 0, "P1": 1, "P6": 6, @@ -868,8 +388,10 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, "P20": 20, - "P21": 21, "P22": 22, "P23": 23, "P24": 24, @@ -885,7 +407,6 @@ BK72XX_BOARD_PINS = { "SCK": 14, "SCL1": 20, "SCL2": 0, - "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, @@ -894,19 +415,61 @@ BK72XX_BOARD_PINS = { "D2": 26, "D3": 24, "D4": 6, - "D5": 7, + "D5": 9, "D6": 0, "D7": 1, - "D8": 9, - "D9": 8, + "D8": 8, + "D9": 7, "D10": 10, "D11": 11, - "D12": 22, - "D13": 21, + "D12": 15, + "D13": 22, "D14": 20, + "D15": 17, + "D16": 16, "A0": 23, }, - "lsc-lma35": { + "cblc5": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P21": 21, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 11, + "D4": 10, + "D5": 1, + "D6": 0, + "D7": 21, + }, + "cbu": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, "WIRE2_SCL": 0, "WIRE2_SDA": 1, "SERIAL1_RX": 10, @@ -914,6 +477,8 @@ BK72XX_BOARD_PINS = { "SERIAL2_RX": 1, "SERIAL2_TX": 0, "ADC3": 23, + "CS": 15, + "MISO": 17, "MOSI": 16, "P0": 0, "P1": 1, @@ -924,12 +489,16 @@ BK72XX_BOARD_PINS = { "P10": 10, "P11": 11, "P14": 14, + "P15": 15, "P16": 16, + "P17": 17, + "P20": 20, "P21": 21, "P22": 22, "P23": 23, "P24": 24, "P26": 26, + "P28": 28, "PWM0": 6, "PWM1": 7, "PWM2": 8, @@ -939,28 +508,405 @@ BK72XX_BOARD_PINS = { "RX1": 10, "RX2": 1, "SCK": 14, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, + "D0": 14, + "D1": 16, + "D2": 20, + "D3": 22, + "D4": 23, + "D5": 1, + "D6": 0, "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, + "D8": 7, + "D9": 6, + "D10": 26, + "D11": 24, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 9, + "D16": 17, + "D17": 15, + "D18": 21, "A0": 23, }, + "generic-bk7231n-qfn32": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7231n-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7231t-qfn32-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 23, + }, + "generic-bk7238": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, + "generic-bk7238-tuya": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 0, + "D1": 1, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 14, + "D9": 15, + "D10": 16, + "D11": 17, + "D12": 20, + "D13": 21, + "D14": 22, + "D15": 23, + "D16": 24, + "D17": 26, + "D18": 28, + "A0": 1, + "A1": 10, + "A2": 20, + "A3": 24, + "A4": 26, + "A5": 28, + }, "generic-bk7252": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1085,6 +1031,161 @@ BK72XX_BOARD_PINS = { "A6": 12, "A7": 13, }, + "lsc-lma35": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "lsc-lma35-t": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P16": 16, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 14, + "D2": 16, + "D3": 24, + "D4": 22, + "D5": 0, + "D6": 23, + "D7": 8, + "D8": 9, + "D9": 21, + "D10": 6, + "D11": 7, + "D12": 10, + "D13": 11, + "D14": 1, + "A0": 23, + }, + "t1-2s": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, "t1-3s": { "SPI0_CS": 15, "SPI0_MISO": 17, @@ -1154,6 +1255,217 @@ BK72XX_BOARD_PINS = { "A3": 26, "A4": 10, }, + "t1-m": { + "WIRE2_SCL": 24, + "WIRE2_SDA": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC5": 1, + "ADC6": 10, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 24, + "SDA2": 26, + "TX1": 11, + "TX2": 0, + "D0": 26, + "D1": 6, + "D2": 8, + "D3": 1, + "D4": 10, + "D5": 11, + "D6": 9, + "D7": 24, + "D11": 0, + "A0": 26, + "A1": 10, + "A2": 1, + "A3": 24, + }, + "t1-u": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 14, + "D1": 16, + "D2": 23, + "D3": 22, + "D4": 20, + "D5": 1, + "D6": 0, + "D7": 24, + "D8": 9, + "D9": 26, + "D10": 6, + "D11": 8, + "D12": 11, + "D13": 10, + "D14": 28, + "D15": 21, + "D16": 17, + "D17": 15, + "A0": 20, + "A1": 1, + "A2": 24, + "A3": 26, + "A4": 10, + "A5": 28, + }, + "wa2": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_TX": 0, + "ADC1": 4, + "ADC3": 23, + "P0": 0, + "P4": 4, + "P6": 6, + "P7": 7, + "P8": 8, + "P10": 10, + "P11": 11, + "P18": 18, + "P19": 19, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM4": 18, + "PWM5": 19, + "RX1": 10, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "TX1": 11, + "TX2": 0, + "D0": 8, + "D1": 7, + "D2": 6, + "D3": 23, + "D4": 10, + "D5": 11, + "D6": 18, + "D7": 19, + "D8": 20, + "D9": 4, + "D10": 0, + "D11": 21, + "D12": 22, + "A0": 23, + }, + "wb1s": { + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL2": 0, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 11, + "D1": 10, + "D2": 26, + "D3": 24, + "D4": 0, + "D5": 8, + "D6": 7, + "D7": 1, + "D8": 9, + "D9": 6, + "D10": 23, + "A0": 23, + }, "wb2l": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, @@ -1205,51 +1517,7 @@ BK72XX_BOARD_PINS = { "D12": 22, "A0": 23, }, - "wb1s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 11, - "D1": 10, - "D2": 26, - "D3": 24, - "D4": 0, - "D5": 8, - "D6": 7, - "D7": 1, - "D8": 9, - "D9": 6, - "D10": 23, - "A0": 23, - }, - "wblc5": { + "wb2l-m1": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1262,6 +1530,8 @@ BK72XX_BOARD_PINS = { "P0": 0, "P1": 1, "P6": 6, + "P7": 7, + "P8": 8, "P10": 10, "P11": 11, "P20": 20, @@ -1271,95 +1541,43 @@ BK72XX_BOARD_PINS = { "P24": 24, "P26": 26, "PWM0": 6, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL1": 20, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 24, - "D1": 6, - "D2": 26, - "D3": 10, - "D4": 11, - "D5": 1, - "D6": 0, - "D7": 20, - "D8": 21, - "D9": 22, - "D10": 23, - "A0": 23, - }, - "cb2s": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, "PWM1": 7, "PWM2": 8, "PWM4": 24, "PWM5": 26, "RX1": 10, "RX2": 1, + "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 6, + "D0": 8, "D1": 7, - "D2": 8, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, + "D2": 6, + "D3": 26, + "D4": 24, + "D5": 10, + "D6": 11, + "D7": 1, "D8": 0, - "D9": 1, + "D9": 20, "D10": 21, + "D11": 23, + "D12": 22, "A0": 23, }, - "generic-bk7238": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL_0": 15, - "WIRE2_SCL_1": 24, - "WIRE2_SDA_0": 17, - "WIRE2_SDA_1": 26, + "wb2s": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, "SERIAL1_RX": 10, "SERIAL1_TX": 11, "SERIAL2_RX": 1, "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC3": 20, - "ADC4": 28, - "ADC5": 1, - "ADC6": 10, - "CS": 15, - "MISO": 17, - "MOSI": 16, + "ADC3": 23, "P0": 0, "P1": 1, "P6": 6, @@ -1368,17 +1586,12 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, "P20": 20, "P21": 21, "P22": 22, "P23": 23, "P24": 24, "P26": 26, - "P28": 28, "PWM0": 6, "PWM1": 7, "PWM2": 8, @@ -1387,65 +1600,10 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, - "SCK": 14, - "TX1": 11, - "TX2": 0, - "D0": 0, - "D1": 1, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 14, - "D9": 15, - "D10": 16, - "D11": 17, - "D12": 20, - "D13": 21, - "D14": 22, - "D15": 23, - "D16": 24, - "D17": 26, - "D18": 28, - "A0": 1, - "A1": 10, - "A2": 20, - "A3": 24, - "A4": 26, - "A5": 28, - }, - "wa2": { - "WIRE1_SCL": 20, - "WIRE1_SDA": 21, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC1": 4, - "ADC3": 23, - "P0": 0, - "P4": 4, - "P6": 6, - "P7": 7, - "P8": 8, - "P10": 10, - "P11": 11, - "P18": 18, - "P19": 19, - "P20": 20, - "P21": 21, - "P22": 22, - "P23": 23, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM4": 18, - "PWM5": 19, - "RX1": 10, "SCL1": 20, "SCL2": 0, "SDA1": 21, + "SDA2": 1, "TX1": 11, "TX2": 0, "D0": 8, @@ -1454,176 +1612,14 @@ BK72XX_BOARD_PINS = { "D3": 23, "D4": 10, "D5": 11, - "D6": 18, - "D7": 19, + "D6": 24, + "D7": 26, "D8": 20, - "D9": 4, - "D10": 0, - "D11": 21, - "D12": 22, - "A0": 23, - }, - "cb3l": { - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_TX": 0, - "ADC3": 23, - "P0": 0, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P21": 21, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 21, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "A0": 23, - }, - "lsc-lma35-t": { - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P16": 16, - "P21": 21, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL2": 0, - "SDA1": 21, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 14, - "D2": 16, - "D3": 24, - "D4": 22, - "D5": 0, - "D6": 23, - "D7": 8, - "D8": 9, - "D9": 21, - "D10": 6, - "D11": 7, - "D12": 10, - "D13": 11, - "D14": 1, - "A0": 23, - }, - "cb3se": { - "SPI0_CS": 15, - "SPI0_MISO": 17, - "SPI0_MOSI": 16, - "SPI0_SCK": 14, - "WIRE2_SCL": 0, - "WIRE2_SDA": 1, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC3": 23, - "CS": 15, - "MISO": 17, - "MOSI": 16, - "P0": 0, - "P1": 1, - "P6": 6, - "P7": 7, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P14": 14, - "P15": 15, - "P16": 16, - "P17": 17, - "P20": 20, - "P22": 22, - "P23": 23, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM1": 7, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCK": 14, - "SCL1": 20, - "SCL2": 0, - "SDA2": 1, - "TX1": 11, - "TX2": 0, - "D0": 23, - "D1": 14, - "D2": 26, - "D3": 24, - "D4": 6, - "D5": 9, - "D6": 0, - "D7": 1, - "D8": 8, - "D9": 7, - "D10": 10, - "D11": 11, - "D12": 15, + "D9": 9, + "D10": 1, + "D11": 0, + "D12": 21, "D13": 22, - "D14": 20, - "D15": 17, - "D16": 16, "A0": 23, }, "wb3l": { @@ -1686,52 +1682,7 @@ BK72XX_BOARD_PINS = { "D15": 1, "A0": 23, }, - "t1-2s": { - "WIRE2_SCL": 24, - "WIRE2_SDA": 26, - "SERIAL1_RX": 10, - "SERIAL1_TX": 11, - "SERIAL2_RX": 1, - "SERIAL2_TX": 0, - "ADC1": 26, - "ADC2": 24, - "ADC5": 1, - "ADC6": 10, - "P0": 0, - "P1": 1, - "P6": 6, - "P8": 8, - "P9": 9, - "P10": 10, - "P11": 11, - "P24": 24, - "P26": 26, - "PWM0": 6, - "PWM2": 8, - "PWM3": 9, - "PWM4": 24, - "PWM5": 26, - "RX1": 10, - "RX2": 1, - "SCL2": 24, - "SDA2": 26, - "TX1": 11, - "TX2": 0, - "D0": 26, - "D1": 6, - "D2": 8, - "D3": 1, - "D4": 10, - "D5": 11, - "D6": 9, - "D7": 24, - "D11": 0, - "A0": 26, - "A1": 10, - "A2": 1, - "A3": 24, - }, - "wb2s": { + "wb3s": { "WIRE1_SCL": 20, "WIRE1_SDA": 21, "WIRE2_SCL": 0, @@ -1749,6 +1700,7 @@ BK72XX_BOARD_PINS = { "P9": 9, "P10": 10, "P11": 11, + "P14": 14, "P20": 20, "P21": 21, "P22": 22, @@ -1763,28 +1715,152 @@ BK72XX_BOARD_PINS = { "PWM5": 26, "RX1": 10, "RX2": 1, + "SCK": 14, "SCL1": 20, "SCL2": 0, "SDA1": 21, "SDA2": 1, "TX1": 11, "TX2": 0, - "D0": 8, - "D1": 7, - "D2": 6, - "D3": 23, - "D4": 10, - "D5": 11, - "D6": 24, - "D7": 26, - "D8": 20, - "D9": 9, - "D10": 1, - "D11": 0, - "D12": 21, - "D13": 22, + "D0": 23, + "D1": 14, + "D2": 26, + "D3": 24, + "D4": 6, + "D5": 7, + "D6": 0, + "D7": 1, + "D8": 9, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 22, + "D13": 21, + "D14": 20, "A0": 23, }, + "wblc5": { + "WIRE1_SCL": 20, + "WIRE1_SDA": 21, + "WIRE2_SCL": 0, + "WIRE2_SDA": 1, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC3": 23, + "P0": 0, + "P1": 1, + "P6": 6, + "P10": 10, + "P11": 11, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "PWM0": 6, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCL1": 20, + "SCL2": 0, + "SDA1": 21, + "SDA2": 1, + "TX1": 11, + "TX2": 0, + "D0": 24, + "D1": 6, + "D2": 26, + "D3": 10, + "D4": 11, + "D5": 1, + "D6": 0, + "D7": 20, + "D8": 21, + "D9": 22, + "D10": 23, + "A0": 23, + }, + "xh-wb3s": { + "SPI0_CS": 15, + "SPI0_MISO": 17, + "SPI0_MOSI": 16, + "SPI0_SCK": 14, + "WIRE2_SCL_0": 15, + "WIRE2_SCL_1": 24, + "WIRE2_SDA_0": 17, + "WIRE2_SDA_1": 26, + "SERIAL1_RX": 10, + "SERIAL1_TX": 11, + "SERIAL2_RX": 1, + "SERIAL2_TX": 0, + "ADC1": 26, + "ADC2": 24, + "ADC3": 20, + "ADC4": 28, + "ADC5": 1, + "ADC6": 10, + "CS": 15, + "MISO": 17, + "MOSI": 16, + "P0": 0, + "P1": 1, + "P6": 6, + "P7": 7, + "P8": 8, + "P9": 9, + "P10": 10, + "P11": 11, + "P14": 14, + "P15": 15, + "P16": 16, + "P17": 17, + "P20": 20, + "P21": 21, + "P22": 22, + "P23": 23, + "P24": 24, + "P26": 26, + "P28": 28, + "PWM0": 6, + "PWM1": 7, + "PWM2": 8, + "PWM3": 9, + "PWM4": 24, + "PWM5": 26, + "RX1": 10, + "RX2": 1, + "SCK": 14, + "TX1": 11, + "TX2": 0, + "D0": 7, + "D1": 23, + "D2": 14, + "D3": 26, + "D4": 24, + "D5": 6, + "D6": 9, + "D7": 0, + "D8": 1, + "D9": 8, + "D10": 10, + "D11": 11, + "D12": 16, + "D13": 20, + "D14": 21, + "D15": 22, + "D16": 15, + "D17": 17, + "A0": 28, + "A1": 26, + "A2": 24, + "A3": 1, + "A4": 10, + "A5": 20, + }, } BOARDS = BK72XX_BOARDS diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index d387757051..9a27cffd04 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -205,7 +205,7 @@ void BL0906::read_data_(const uint8_t address, const float reference, sensor::Se // Chip temperature if (reference == BL0906_TREF) { value = (float) to_int32_t(data_s24); - value = (value - 64) * 12.5 / 59 - 40; + value = (value - 64) * 12.5f / 59 - 40; } sensor->publish_state(value); } 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.cpp b/esphome/components/bl0940/bl0940.cpp index b7df603f2f..642368d93f 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -120,7 +120,7 @@ float BL0940::calculate_power_reference_() { float BL0940::calculate_energy_reference_() { // formula: 3600000 * 4046 * RL * R1 * 1000 / (1638.4 * 256) / Vref² / (R1 + R2) // or: power_reference_ * 3600000 / (1638.4 * 256) - return this->power_reference_cal_ * 3600000 / (1638.4 * 256); + return this->power_reference_cal_ * 3600000 / (1638.4f * 256); } float BL0940::calculate_calibration_value_(float state) { return (100 + state) / 100; } 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/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index 992064943b..96445d5c38 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -211,6 +211,17 @@ CONFIG_SCHEMA = ( .add_extra(set_reference_values) ) +# BL0940 datasheet: 4800 baud, 8 data bits, no parity (stop bits are 1.5 -- not +# representable in the uart schema, so it isn't asserted). +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "bl0940", + baud_rate=4800, + data_bits=8, + parity="NONE", + require_rx=True, + require_tx=True, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 1c57616c82..e952df21be 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -124,14 +124,14 @@ void BL0942::setup() { // If either current or voltage references are set explicitly by the user, // calculate the power reference from it unless that is also explicitly set. if ((this->current_reference_set_ || this->voltage_reference_set_) && !this->power_reference_set_) { - this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0 / 305978.0) / 73989.0; + this->power_reference_ = (this->voltage_reference_ * this->current_reference_ * 3537.0f / 305978.0f) / 73989.0f; this->power_reference_set_ = true; } // Similarly for energy reference, if the power reference was set by the user // either implicitly or explicitly. if (this->power_reference_set_ && !this->energy_reference_set_) { - this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4; + this->energy_reference_ = this->power_reference_ * 3600000 / 419430.4f; this->energy_reference_set_ = true; } 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.cpp b/esphome/components/ble_nus/ble_nus.cpp index 71d98332e0..b566122f8a 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -25,11 +25,14 @@ void BLENUS::write_array(const uint8_t *data, size_t len) { if (atomic_get(&this->tx_status_) == TX_DISABLED) { return; } - auto sent = ring_buf_put(&global_ble_tx_ring_buf, data, len); - if (sent < len) { - ESP_LOGE(TAG, "TX dropping %u bytes", len - sent); + // ring_buf_put() performs a partial write when the buffer is nearly full, which would commit a + // truncated fragment and corrupt the stream. Only write when the whole payload fits, so the byte + // stream never contains a partial message. + if (ring_buf_space_get(&global_ble_tx_ring_buf) < len) { + ESP_LOGE(TAG, "TX dropping %u bytes", len); return; } + ring_buf_put(&global_ble_tx_ring_buf, data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(uart::UART_DIRECTION_TX, data[i]); @@ -197,6 +200,10 @@ void BLENUS::setup() { void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; + // make sure there is space for '\n' or entire message is dropped + if (ring_buf_space_get(&global_ble_tx_ring_buf) < message_len + 1) { + return; + } this->write_array(reinterpret_cast(message), message_len); const char c = '\n'; this->write_array(reinterpret_cast(&c), 1); 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/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c2d48741b1..c70ee637ef 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -12,7 +12,9 @@ namespace esphome::ble_scanner { -class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLEScanner final : public text_sensor::TextSensor, + public esp32_ble_tracker::ESPBTDeviceListener, + public Component { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index ca30aab943..37ebcad8b4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -379,9 +379,17 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn } void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); - return; + if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { + // A previous subscriber still holds the slot. This is almost always a stale + // connection from a client that dropped without a clean disconnect and has + // not yet hit the keepalive timeout; rejecting the new subscriber would + // silently starve it of advertisements until it reconnects, so the newest + // subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); } this->api_connection_ = api_connection; this->parent_->recalculate_advertisement_parser_types(); diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index eda2d1b3c0..5ca9714091 100644 --- a/esphome/components/bm8563/bm8563.h +++ b/esphome/components/bm8563/bm8563.h @@ -5,7 +5,7 @@ namespace esphome::bm8563 { -class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { +class BM8563 final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -34,17 +34,17 @@ class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { uint8_t byte_to_bcd2_(uint8_t value); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; -template class TimerAction : public Action, public Parented { +template class TimerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint32_t, duration) diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index ad4a283fc7..501556d3c4 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -7,7 +7,7 @@ namespace esphome::bme280_i2c { static const char *const TAG = "bme280_i2c.sensor"; -class BME280I2CComponent : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { +class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h index 4e842e9596..3879151ea1 100644 --- a/esphome/components/bme280_spi/bme280_spi.h +++ b/esphome/components/bme280_spi/bme280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bme280_spi { -class BME280SPIComponent : public esphome::bme280_base::BME280Component, - public spi::SPIDevice { +class BME280SPIComponent final : public esphome::bme280_base::BME280Component, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index e40daf8720..a274578fc1 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -65,7 +65,7 @@ struct BME680CalibrationData { int8_t ambient_temperature; }; -class BME680Component : public PollingComponent, public i2c::I2CDevice { +class BME680Component final : public PollingComponent, public i2c::I2CDevice { public: /// Set the temperature oversampling value. Defaults to 16X. void set_temperature_oversampling(BME680Oversampling temperature_oversampling); diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 2365f8d107..e1e01facd0 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import esp32, i2c +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, Framework @@ -12,7 +13,6 @@ MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" CONF_IAQ_MODE = "iaq_mode" CONF_SUPPLY_VOLTAGE = "supply_voltage" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" bme680_bsec_ns = cg.esphome_ns.namespace("bme680_bsec") diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h index 742b07b59b..ff974d1c6f 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.h +++ b/esphome/components/bme680_bsec/bme680_bsec.h @@ -34,7 +34,7 @@ enum SampleRate { #define BME680_BSEC_SAMPLE_RATE_LOG(r) (r == SAMPLE_RATE_DEFAULT ? "Default" : (r == SAMPLE_RATE_ULP ? "ULP" : "LP")) -class BME680BSECComponent : public Component, public i2c::I2CDevice { +class BME680BSECComponent final : public Component, public i2c::I2CDevice { public: void set_device_id(const std::string &devid) { this->device_id_.assign(devid); } void set_temperature_offset(float offset) { this->temperature_offset_ = offset; } diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index 8d3ae76e3f..bdc8d8f2d3 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME680_BSEC_ID, SAMPLE_RATE_OPTIONS, BME680BSECComponent DEPENDENCIES = ["bme680_bsec"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 62cd9e2e36..63f63c5da2 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import core, external_files import esphome.codegen as cg +from esphome.components.const import CONF_STATE_SAVE_INTERVAL import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -24,7 +25,6 @@ CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" CONF_IAQ_MODE = "iaq_mode" CONF_OPERATING_AGE = "operating_age" -CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_SUPPLY_VOLTAGE = "supply_voltage" bme68x_bsec2_ns = cg.esphome_ns.namespace("bme68x_bsec2") diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index f21a9b8138..52587dba99 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -1,5 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import ( + CONF_BREATH_VOC_EQUIVALENT, + CONF_CO2_EQUIVALENT, + CONF_IAQ, +) import esphome.config_validation as cv from esphome.const import ( CONF_GAS_RESISTANCE, @@ -29,9 +34,6 @@ from . import CONF_BME68X_BSEC2_ID, SAMPLE_RATE_OPTIONS, BME68xBSEC2Component DEPENDENCIES = ["bme68x_bsec2"] -CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" -CONF_CO2_EQUIVALENT = "co2_equivalent" -CONF_IAQ = "iaq" CONF_IAQ_STATIC = "iaq_static" ICON_ACCURACY = "mdi:checkbox-marked-circle-outline" UNIT_IAQ = "IAQ" diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h index 6d20b61390..896d00d096 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.h @@ -11,7 +11,7 @@ namespace esphome::bme68x_bsec2_i2c { -class BME68xBSEC2I2CComponent : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { +class BME68xBSEC2I2CComponent final : public bme68x_bsec2::BME68xBSEC2Component, public i2c::I2CDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/bmi160/bmi160.h b/esphome/components/bmi160/bmi160.h index e86c353eaa..8af25a09ad 100644 --- a/esphome/components/bmi160/bmi160.h +++ b/esphome/components/bmi160/bmi160.h @@ -6,7 +6,7 @@ namespace esphome::bmi160 { -class BMI160Component : public PollingComponent, public i2c::I2CDevice { +class BMI160Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 7c5a2db015..56d6a60952 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -78,7 +78,7 @@ enum BMI270GyroODR : uint8_t { // ---Data class // Main component class -class BMI270Component : public motion::MotionComponent, public i2c::I2CDevice { +class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { public: // Lifecycle void setup() override; diff --git a/esphome/components/bmp085/bmp085.h b/esphome/components/bmp085/bmp085.h index a64f3936f0..7012152257 100644 --- a/esphome/components/bmp085/bmp085.h +++ b/esphome/components/bmp085/bmp085.h @@ -6,7 +6,7 @@ namespace esphome::bmp085 { -class BMP085Component : public PollingComponent, public i2c::I2CDevice { +class BMP085Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_pressure(sensor::Sensor *pressure) { pressure_ = pressure; } diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index bf1c2fd624..a19203ff0a 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp280_i2c { static const char *const TAG = "bmp280_i2c.sensor"; /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. -class BMP280I2CComponent : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { +class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp280_spi/bmp280_spi.h b/esphome/components/bmp280_spi/bmp280_spi.h index 17d3999884..449167811d 100644 --- a/esphome/components/bmp280_spi/bmp280_spi.h +++ b/esphome/components/bmp280_spi/bmp280_spi.h @@ -5,9 +5,9 @@ namespace esphome::bmp280_spi { -class BMP280SPIComponent : public esphome::bmp280_base::BMP280Component, - public spi::SPIDevice { +class BMP280SPIComponent final : public esphome::bmp280_base::BMP280Component, + public spi::SPIDevice { void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; bool bmp_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h index bec99cf9f8..93549fc890 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.h @@ -4,7 +4,7 @@ namespace esphome::bmp3xx_i2c { -class BMP3XXI2CComponent : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { +class BMP3XXI2CComponent final : public bmp3xx_base::BMP3XXComponent, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.h b/esphome/components/bmp3xx_spi/bmp3xx_spi.h index fa0c0e1b47..7e101cc3a1 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.h +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.h @@ -4,9 +4,9 @@ namespace esphome::bmp3xx_spi { -class BMP3XXSPIComponent : public bmp3xx_base::BMP3XXComponent, - public spi::SPIDevice { +class BMP3XXSPIComponent final : public bmp3xx_base::BMP3XXComponent, + public spi::SPIDevice { void setup() override; bool read_byte(uint8_t a_register, uint8_t *data) override; bool write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index a4e43daf64..126ffd6a60 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -8,7 +8,7 @@ namespace esphome::bmp581_i2c { static const char *const TAG = "bmp581_i2c.sensor"; /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. -class BMP581I2CComponent : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { +class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { public: bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/bmp581_spi/bmp581_spi.h b/esphome/components/bmp581_spi/bmp581_spi.h index 57f75588d5..e5b6cf4476 100644 --- a/esphome/components/bmp581_spi/bmp581_spi.h +++ b/esphome/components/bmp581_spi/bmp581_spi.h @@ -6,9 +6,9 @@ namespace esphome::bmp581_spi { // BMP581 is technically compatible with SPI Mode0 and Mode3. Default to Mode3. -class BMP581SPIComponent : public esphome::bmp581_base::BMP581Component, - public spi::SPIDevice { +class BMP581SPIComponent final : public esphome::bmp581_base::BMP581Component, + public spi::SPIDevice { public: void setup() override; bool bmp_read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bp1658cj/bp1658cj.h b/esphome/components/bp1658cj/bp1658cj.h index 8905642ec4..666a145804 100644 --- a/esphome/components/bp1658cj/bp1658cj.h +++ b/esphome/components/bp1658cj/bp1658cj.h @@ -7,7 +7,7 @@ namespace esphome::bp1658cj { -class BP1658CJ : public Component { +class BP1658CJ final : public Component { public: class Channel; @@ -29,7 +29,7 @@ class BP1658CJ : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP1658CJ *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bp5758d/bp5758d.h b/esphome/components/bp5758d/bp5758d.h index f07d51fe51..572108b4e6 100644 --- a/esphome/components/bp5758d/bp5758d.h +++ b/esphome/components/bp5758d/bp5758d.h @@ -7,7 +7,7 @@ namespace esphome::bp5758d { -class BP5758D : public Component { +class BP5758D final : public Component { public: class Channel; @@ -23,7 +23,7 @@ class BP5758D : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(BP5758D *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 9bec8ba7a1..924858e449 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -12,7 +12,7 @@ namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index dd4fde5705..a4245f43e6 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -50,7 +50,9 @@ _BUTTON_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), } ) diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index 6a54b141a3..d55d43ea37 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -6,7 +6,7 @@ namespace esphome::button { -template class PressAction : public Action { +template class PressAction final : public Action { public: explicit PressAction(Button *button) : button_(button) {} @@ -16,7 +16,7 @@ template class PressAction : public Action { Button *button_; }; -class ButtonPressTrigger : public Trigger<> { +class ButtonPressTrigger final : public Trigger<> { public: ButtonPressTrigger(Button *button) { button->add_on_press_callback([this]() { this->trigger(); }); diff --git a/esphome/components/camera_encoder/encoder_buffer_impl.h b/esphome/components/camera_encoder/encoder_buffer_impl.h index d394daff14..b506cb47e0 100644 --- a/esphome/components/camera_encoder/encoder_buffer_impl.h +++ b/esphome/components/camera_encoder/encoder_buffer_impl.h @@ -5,7 +5,7 @@ namespace esphome::camera_encoder { -class EncoderBufferImpl : public camera::EncoderBuffer { +class EncoderBufferImpl final : public camera::EncoderBuffer { public: // --- EncoderBuffer --- bool set_buffer_size(size_t size) override; diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h index 0ede366e73..5ec6a98cb9 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h @@ -11,7 +11,7 @@ namespace esphome::camera_encoder { /// Encoder that uses the software-based JPEG implementation from Espressif's esp32-camera component. -class ESP32CameraJPEGEncoder : public camera::Encoder { +class ESP32CameraJPEGEncoder final : public camera::Encoder { public: /// Constructs a ESP32CameraJPEGEncoder instance. /// @param quality Sets the quality of the encoded image (1-100). diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 691d7384f1..1bc4d6e345 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -106,7 +106,7 @@ class Canbus : public Component { virtual Error read_message(struct CanFrame *frame) = 0; }; -template class CanbusSendAction : public Action, public Parented { +template class CanbusSendAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers @@ -154,7 +154,7 @@ template class CanbusSendAction : public Action, public P } data_; }; -class CanbusTrigger : public Trigger, uint32_t, bool>, public Component { +class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { friend class Canbus; public: diff --git a/esphome/components/cap1188/cap1188.h b/esphome/components/cap1188/cap1188.h index 848e6fe430..a4abb270e7 100644 --- a/esphome/components/cap1188/cap1188.h +++ b/esphome/components/cap1188/cap1188.h @@ -26,7 +26,7 @@ enum { CAP1188_SENSITVITY = 0x1f, }; -class CAP1188Channel : public binary_sensor::BinarySensor { +class CAP1188Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint8_t data) { this->publish_state(static_cast(data & (1 << this->channel_))); } @@ -35,7 +35,7 @@ class CAP1188Channel : public binary_sensor::BinarySensor { uint8_t channel_{0}; }; -class CAP1188Component : public Component, public i2c::I2CDevice { +class CAP1188Component final : public Component, public i2c::I2CDevice { public: void register_channel(CAP1188Channel *channel) { this->channels_.push_back(channel); } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cd877fc879..703ae98392 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), @@ -105,7 +105,7 @@ async def to_code(config): if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2040): + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): cg.add_library("DNSServer", None) diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 8c8b43e608..b47af9d978 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -14,7 +14,7 @@ namespace esphome::captive_portal { -class CaptivePortal : public AsyncWebHandler, public Component { +class CaptivePortal final : public AsyncWebHandler, public Component { public: CaptivePortal(web_server_base::WebServerBase *base); void setup() override; diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 000a13d586..065ffd5250 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -16,9 +16,9 @@ class CC1101Listener { virtual void on_packet(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) = 0; }; -class CC1101Component : public Component, - public spi::SPIDevice { +class CC1101Component final : public Component, + public spi::SPIDevice { public: CC1101Component(); @@ -119,27 +119,27 @@ class CC1101Component : public Component, }; // Action Wrappers -template class BeginTxAction : public Action, public Parented { +template class BeginTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_tx(); } }; -template class BeginRxAction : public Action, public Parented { +template class BeginRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->begin_rx(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetIdleAction : public Action, public Parented { +template class SetIdleAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_idle(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } void set_data_static(const uint8_t *data, size_t len) { @@ -163,79 +163,80 @@ template class SendPacketAction : public Action, public P size_t data_static_len_{0}; }; -template class SetSymbolRateAction : public Action, public Parented { +template class SetSymbolRateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, symbol_rate) void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } }; -template class SetFrequencyAction : public Action, public Parented { +template class SetFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, frequency) void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } }; -template class SetOutputPowerAction : public Action, public Parented { +template class SetOutputPowerAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, output_power) void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } }; -template class SetModulationTypeAction : public Action, public Parented { +template class SetModulationTypeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Modulation, modulation_type) void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } }; -template class SetRxAttenuationAction : public Action, public Parented { +template class SetRxAttenuationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } }; -template class SetDcBlockingFilterAction : public Action, public Parented { +template +class SetDcBlockingFilterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, dc_blocking_filter) void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } }; -template class SetManchesterAction : public Action, public Parented { +template class SetManchesterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, manchester) void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } }; -template class SetFilterBandwidthAction : public Action, public Parented { +template class SetFilterBandwidthAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, filter_bandwidth) void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } }; -template class SetFskDeviationAction : public Action, public Parented { +template class SetFskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, fsk_deviation) void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } }; -template class SetMskDeviationAction : public Action, public Parented { +template class SetMskDeviationAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, msk_deviation) void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } }; -template class SetChannelSpacingAction : public Action, public Parented { +template class SetChannelSpacingAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, channel_spacing) void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } }; -template class SetIfFrequencyAction : public Action, public Parented { +template class SetIfFrequencyAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, if_frequency) void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } diff --git a/esphome/components/ccs811/ccs811.h b/esphome/components/ccs811/ccs811.h index fde2494753..fb83f842fd 100644 --- a/esphome/components/ccs811/ccs811.h +++ b/esphome/components/ccs811/ccs811.h @@ -8,7 +8,7 @@ namespace esphome::ccs811 { -class CCS811Component : public PollingComponent, public i2c::I2CDevice { +class CCS811Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/cd74hc4067/cd74hc4067.h b/esphome/components/cd74hc4067/cd74hc4067.h index f41b5e294a..3e773a3c8c 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.h +++ b/esphome/components/cd74hc4067/cd74hc4067.h @@ -7,7 +7,7 @@ namespace esphome::cd74hc4067 { -class CD74HC4067Component : public Component { +class CD74HC4067Component final : public Component { public: /// Set up the internal sensor array. void setup() override; @@ -38,7 +38,7 @@ class CD74HC4067Component : public Component { uint32_t switch_delay_; }; -class CD74HC4067Sensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { +class CD74HC4067Sensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { public: CD74HC4067Sensor(CD74HC4067Component *parent); diff --git a/esphome/components/ch422g/ch422g.h b/esphome/components/ch422g/ch422g.h index f74e0c46a4..a8729225e8 100644 --- a/esphome/components/ch422g/ch422g.h +++ b/esphome/components/ch422g/ch422g.h @@ -6,7 +6,7 @@ namespace esphome::ch422g { -class CH422GComponent : public Component, public i2c::I2CDevice { +class CH422GComponent final : public Component, public i2c::I2CDevice { public: CH422GComponent() = default; @@ -42,7 +42,7 @@ class CH422GComponent : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH422G pin as a GPIO pin. -class CH422GGPIOPin : public GPIOPin { +class CH422GGPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/ch423/ch423.h b/esphome/components/ch423/ch423.h index d384971a72..fbfffb521d 100644 --- a/esphome/components/ch423/ch423.h +++ b/esphome/components/ch423/ch423.h @@ -6,7 +6,7 @@ namespace esphome::ch423 { -class CH423Component : public Component, public i2c::I2CDevice { +class CH423Component final : public Component, public i2c::I2CDevice { public: CH423Component() = default; @@ -41,7 +41,7 @@ class CH423Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a CH423 pin as a GPIO pin. -class CH423GPIOPin : public GPIOPin { +class CH423GPIOPin final : public GPIOPin { public: void setup() override{}; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 32077b3d33..84e539e5f2 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -17,7 +17,7 @@ static const uint8_t CHSC6X_REG_STATUS_Y_COR = 0x04; static const uint8_t CHSC6X_REG_STATUS_LEN = 0x05; static const uint8_t CHSC6X_CHIP_ID = 0x2e; -class CHSC6XTouchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CHSC6XTouchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 6ac9bd8bae..a8d6d778ae 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -17,7 +17,7 @@ namespace esphome::climate { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t &...); ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {} @@ -33,14 +33,14 @@ template class ControlAction : public Action { ApplyFn apply_; }; -class ControlTrigger : public Trigger { +class ControlTrigger final : public Trigger { public: ControlTrigger(Climate *climate) { climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); } }; -class StateTrigger : public Trigger { +class StateTrigger final : public Trigger { public: StateTrigger(Climate *climate) { climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.h b/esphome/components/climate_ir_lg/climate_ir_lg.h index a09da65ac6..341f0a4ef1 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -10,7 +10,7 @@ namespace esphome::climate_ir_lg { const uint8_t TEMP_MIN = 18; // Celsius const uint8_t TEMP_MAX = 30; // Celsius -class LgIrClimate : public climate_ir::ClimateIR { +class LgIrClimate final : public climate_ir::ClimateIR { public: LgIrClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 047e91d632..844bfdfa88 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -7,7 +7,7 @@ namespace esphome::cm1106 { -class CM1106Component : public PollingComponent, public uart::UARTDevice { +class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -23,7 +23,7 @@ class CM1106Component : public PollingComponent, public uart::UARTDevice { bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); }; -template class CM1106CalibrateZeroAction : public Action { +template class CM1106CalibrateZeroAction final : public Action { public: CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} diff --git a/esphome/components/color_temperature/ct_light_output.h b/esphome/components/color_temperature/ct_light_output.h index a4da1011b0..51ca21465e 100644 --- a/esphome/components/color_temperature/ct_light_output.h +++ b/esphome/components/color_temperature/ct_light_output.h @@ -6,7 +6,7 @@ namespace esphome::color_temperature { -class CTLightOutput : public light::LightOutput { +class CTLightOutput final : public light::LightOutput { public: void set_color_temperature(output::FloatOutput *color_temperature) { color_temperature_ = color_temperature; } void set_brightness(output::FloatOutput *brightness) { brightness_ = brightness; } diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index ddf1a105e0..8ef0976e3b 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -204,7 +204,7 @@ void MedianCombinationComponent::handle_new_value(float value) { median = sensor_states[sensor_states_size / 2]; } else { // Even number of measurements, use the average of the two middle measurements - median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0; + median = (sensor_states[sensor_states_size / 2] + sensor_states[sensor_states_size / 2 - 1]) / 2.0f; } } diff --git a/esphome/components/combination/combination.h b/esphome/components/combination/combination.h index 34e9e4e2c6..00745663e0 100644 --- a/esphome/components/combination/combination.h +++ b/esphome/components/combination/combination.h @@ -58,7 +58,7 @@ class CombinationOneParameterComponent : public CombinationComponent { FixedVector sensor_sources_; }; -class KalmanCombinationComponent : public CombinationOneParameterComponent { +class KalmanCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override; void setup() override; @@ -85,7 +85,7 @@ class KalmanCombinationComponent : public CombinationOneParameterComponent { float variance_{INFINITY}; }; -class LinearCombinationComponent : public CombinationOneParameterComponent { +class LinearCombinationComponent final : public CombinationOneParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("linear")); } void setup() override; @@ -93,49 +93,49 @@ class LinearCombinationComponent : public CombinationOneParameterComponent { void handle_new_value(float value); }; -class MaximumCombinationComponent : public CombinationNoParameterComponent { +class MaximumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("max")); } void handle_new_value(float value) override; }; -class MeanCombinationComponent : public CombinationNoParameterComponent { +class MeanCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("mean")); } void handle_new_value(float value) override; }; -class MedianCombinationComponent : public CombinationNoParameterComponent { +class MedianCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("median")); } void handle_new_value(float value) override; }; -class MinimumCombinationComponent : public CombinationNoParameterComponent { +class MinimumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("min")); } void handle_new_value(float value) override; }; -class MostRecentCombinationComponent : public CombinationNoParameterComponent { +class MostRecentCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("most_recently_updated")); } void handle_new_value(float value) override; }; -class RangeCombinationComponent : public CombinationNoParameterComponent { +class RangeCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("range")); } void handle_new_value(float value) override; }; -class SumCombinationComponent : public CombinationNoParameterComponent { +class SumCombinationComponent final : public CombinationNoParameterComponent { public: void dump_config() override { this->log_config_(LOG_STR("sum")); } diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 9951243f0d..6f4fa9aaa7 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,15 +8,19 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_B_CONSTANT = "b_constant" +CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CLIMATE_ID = "climate_id" +CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DRAW_ROUNDING = "draw_rounding" +CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" @@ -28,6 +32,7 @@ CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" CONF_SHA256 = "sha256" +CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_USE_PSRAM = "use_psram" CONF_VOLUME_INCREMENT = "volume_increment" diff --git a/esphome/components/coolix/coolix.h b/esphome/components/coolix/coolix.h index 2d8862e2b6..6a59a58a92 100644 --- a/esphome/components/coolix/coolix.h +++ b/esphome/components/coolix/coolix.h @@ -10,7 +10,7 @@ namespace esphome::coolix { const uint8_t COOLIX_TEMP_MIN = 17; // Celsius const uint8_t COOLIX_TEMP_MAX = 30; // Celsius -class CoolixClimate : public climate_ir::ClimateIR { +class CoolixClimate final : public climate_ir::ClimateIR { public: CoolixClimate() : climate_ir::ClimateIR(COOLIX_TEMP_MIN, COOLIX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.h b/esphome/components/copy/binary_sensor/copy_binary_sensor.h index a6ce705a2a..b30ca9cb21 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.h +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyBinarySensor : public binary_sensor::BinarySensor, public Component { +class CopyBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(binary_sensor::BinarySensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/button/copy_button.h b/esphome/components/copy/button/copy_button.h index afd783375d..bdefcc512a 100644 --- a/esphome/components/copy/button/copy_button.h +++ b/esphome/components/copy/button/copy_button.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyButton : public button::Button, public Component { +class CopyButton final : public button::Button, public Component { public: void set_source(button::Button *source) { source_ = source; } void dump_config() override; diff --git a/esphome/components/copy/cover/copy_cover.h b/esphome/components/copy/cover/copy_cover.h index 0b493e4c3b..008cbdf28e 100644 --- a/esphome/components/copy/cover/copy_cover.h +++ b/esphome/components/copy/cover/copy_cover.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyCover : public cover::Cover, public Component { +class CopyCover final : public cover::Cover, public Component { public: void set_source(cover::Cover *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/fan/copy_fan.h b/esphome/components/copy/fan/copy_fan.h index 9090c91095..4f882ba43d 100644 --- a/esphome/components/copy/fan/copy_fan.h +++ b/esphome/components/copy/fan/copy_fan.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyFan : public fan::Fan, public Component { +class CopyFan final : public fan::Fan, public Component { public: void set_source(fan::Fan *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/lock/copy_lock.h b/esphome/components/copy/lock/copy_lock.h index c6c46467a9..0177db1708 100644 --- a/esphome/components/copy/lock/copy_lock.h +++ b/esphome/components/copy/lock/copy_lock.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyLock : public lock::Lock, public Component { +class CopyLock final : public lock::Lock, public Component { public: void set_source(lock::Lock *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/number/copy_number.h b/esphome/components/copy/number/copy_number.h index b4d8bb83e6..82af6cc8ea 100644 --- a/esphome/components/copy/number/copy_number.h +++ b/esphome/components/copy/number/copy_number.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyNumber : public number::Number, public Component { +class CopyNumber final : public number::Number, public Component { public: void set_source(number::Number *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index 1a17c7a55a..2c541d99bc 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySelect : public select::Select, public Component { +class CopySelect final : public select::Select, public Component { public: void set_source(select::Select *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/sensor/copy_sensor.h b/esphome/components/copy/sensor/copy_sensor.h index d6e5026ce1..136c36de95 100644 --- a/esphome/components/copy/sensor/copy_sensor.h +++ b/esphome/components/copy/sensor/copy_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySensor : public sensor::Sensor, public Component { +class CopySensor final : public sensor::Sensor, public Component { public: void set_source(sensor::Sensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/switch/copy_switch.h b/esphome/components/copy/switch/copy_switch.h index 9ce6b48ed1..bef254093c 100644 --- a/esphome/components/copy/switch/copy_switch.h +++ b/esphome/components/copy/switch/copy_switch.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopySwitch : public switch_::Switch, public Component { +class CopySwitch final : public switch_::Switch, public Component { public: void set_source(switch_::Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text/copy_text.h b/esphome/components/copy/text/copy_text.h index ad28936522..4dd3b5fe5e 100644 --- a/esphome/components/copy/text/copy_text.h +++ b/esphome/components/copy/text/copy_text.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyText : public text::Text, public Component { +class CopyText final : public text::Text, public Component { public: void set_source(text::Text *source) { source_ = source; } void setup() override; diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.h b/esphome/components/copy/text_sensor/copy_text_sensor.h index dc4ef7a29d..e27e3bc1d7 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.h +++ b/esphome/components/copy/text_sensor/copy_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::copy { -class CopyTextSensor : public text_sensor::TextSensor, public Component { +class CopyTextSensor final : public text_sensor::TextSensor, public Component { public: void set_source(text_sensor::TextSensor *source) { source_ = source; } void setup() override; diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 839ca532e6..7639e15334 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -131,7 +131,9 @@ _COVER_SCHEMA = ( cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All( cv.requires_component("mqtt"), cv.boolean ), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index ee7a4f5f76..0a5a447ab9 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -6,7 +6,7 @@ namespace esphome::cover { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Cover *cover) : cover_(cover) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Cover *cover_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Cover *cover) : cover_(cover) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Cover *cover_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Cover *cover) : cover_(cover) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Cover *cover_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Cover *cover) : cover_(cover) {} @@ -59,7 +59,7 @@ template class ToggleAction : public Action { // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t &...); ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -75,7 +75,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class CoverPublishAction : public Action { +template class CoverPublishAction final : public Action { public: using ApplyFn = void (*)(Cover *, const std::remove_cvref_t &...); CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {} @@ -90,7 +90,7 @@ template class CoverPublishAction : public Action { ApplyFn apply_; }; -template class CoverPositionCondition : public Condition { +template class CoverPositionCondition final : public Condition { public: CoverPositionCondition(Cover *cover) : cover_(cover) {} @@ -103,7 +103,7 @@ template class CoverPositionCondition : public Condit template using CoverIsOpenCondition = CoverPositionCondition; template using CoverIsClosedCondition = CoverPositionCondition; -template class CoverPositionTrigger : public Trigger<> { +template class CoverPositionTrigger final : public Trigger<> { public: CoverPositionTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { @@ -123,7 +123,7 @@ template class CoverPositionTrigger : public Trigger<> { using CoverOpenedTrigger = CoverPositionTrigger; using CoverClosedTrigger = CoverPositionTrigger; -template class CoverTrigger : public Trigger<> { +template class CoverTrigger final : public Trigger<> { public: CoverTrigger(Cover *a_cover) : cover_(a_cover) { a_cover->add_on_state_callback([this]() { diff --git a/esphome/components/cs5460a/cs5460a.h b/esphome/components/cs5460a/cs5460a.h index c6b02f53ee..87ea858c70 100644 --- a/esphome/components/cs5460a/cs5460a.h +++ b/esphome/components/cs5460a/cs5460a.h @@ -52,9 +52,9 @@ enum CS5460APGAGain { CS5460A_PGA_GAIN_50X = 0b1, }; -class CS5460AComponent : public Component, - public spi::SPIDevice { +class CS5460AComponent final : public Component, + public spi::SPIDevice { public: void set_samples(uint32_t samples) { samples_ = samples; } void set_phase_offset(int8_t phase_offset) { phase_offset_ = phase_offset; } @@ -108,7 +108,7 @@ class CS5460AComponent : public Component, uint32_t prev_raw_energy_{0}; }; -template class CS5460ARestartAction : public Action { +template class CS5460ARestartAction final : public Action { public: CS5460ARestartAction(CS5460AComponent *cs5460a) : cs5460a_(cs5460a) {} diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index 5f683f424b..e08ebf09cc 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -16,7 +16,7 @@ struct CSE7761DataStruct { }; /// This class implements support for the CSE7761 UART power sensor. -class CSE7761Component : public PollingComponent, public uart::UARTDevice { +class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 77b80dd824..8a57816a59 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -9,7 +9,7 @@ namespace esphome::cse7766 { static constexpr size_t CSE7766_RAW_DATA_SIZE = 24; -class CSE7766Component : public Component, public uart::UARTDevice { +class CSE7766Component final : public Component, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/cst226/binary_sensor/cs226_button.h b/esphome/components/cst226/binary_sensor/cs226_button.h index e7e334b9bb..ec07341e21 100644 --- a/esphome/components/cst226/binary_sensor/cs226_button.h +++ b/esphome/components/cst226/binary_sensor/cs226_button.h @@ -6,10 +6,10 @@ namespace esphome::cst226 { -class CST226Button : public binary_sensor::BinarySensor, - public Component, - public CST226ButtonListener, - public Parented { +class CST226Button final : public binary_sensor::BinarySensor, + public Component, + public CST226ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.h b/esphome/components/cst226/touchscreen/cst226_touchscreen.h index 362eee5fc2..c68c50fb44 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.h +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.h @@ -15,7 +15,7 @@ class CST226ButtonListener { virtual void update_button(bool state) = 0; }; -class CST226Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST226Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/cst328/__init__.py b/esphome/components/cst328/__init__.py new file mode 100644 index 0000000000..374df64898 --- /dev/null +++ b/esphome/components/cst328/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] + +cst328_ns = cg.esphome_ns.namespace("cst328") diff --git a/esphome/components/cst328/binary_sensor/__init__.py b/esphome/components/cst328/binary_sensor/__init__.py new file mode 100644 index 0000000000..6d881cc6c1 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv + +from .. import cst328_ns +from ..touchscreen import CST328ButtonListener, CST328Touchscreen + +CONF_CST328_ID = "cst328_id" + +CST328Button = cst328_ns.class_( + "CST328Button", + binary_sensor.BinarySensor, + cg.Component, + CST328ButtonListener, + cg.Parented.template(CST328Touchscreen), +) + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST328Button).extend( + { + cv.GenerateID(CONF_CST328_ID): cv.use_id(CST328Touchscreen), + } +) + + +async def to_code(config): + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_CST328_ID]) diff --git a/esphome/components/cst328/binary_sensor/cst328_button.cpp b/esphome/components/cst328/binary_sensor/cst328_button.cpp new file mode 100644 index 0000000000..b58f4b4b9f --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.cpp @@ -0,0 +1,16 @@ +#include "cst328_button.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { +static const char *const TAG = "cst328.binary_sensor"; + +void CST328Button::setup() { + this->parent_->register_button_listener(this); + this->publish_initial_state(false); +} + +void CST328Button::dump_config() { LOG_BINARY_SENSOR("", "CST328 Button", this); } + +void CST328Button::update_button(bool state) { this->publish_state(state); } + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/binary_sensor/cst328_button.h b/esphome/components/cst328/binary_sensor/cst328_button.h new file mode 100644 index 0000000000..a9ed4785e5 --- /dev/null +++ b/esphome/components/cst328/binary_sensor/cst328_button.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "../touchscreen/cst328_touchscreen.h" + +namespace esphome::cst328 { + +class CST328Button : public binary_sensor::BinarySensor, + public Component, + public CST328ButtonListener, + public Parented { + public: + void setup() override; + void dump_config() override; + void update_button(bool state) override; +}; + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py new file mode 100644 index 0000000000..18c00bb6c5 --- /dev/null +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -0,0 +1,38 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst328_ns + +CST328Touchscreen = cst328_ns.class_( + "CST328Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CST328ButtonListener = cst328_ns.class_("CST328ButtonListener") + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST328Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x1A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp new file mode 100644 index 0000000000..5e1a2ebf72 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp @@ -0,0 +1,168 @@ +#include "cst328_touchscreen.h" +#include "esphome/core/log.h" + +namespace esphome::cst328 { + +static const char *const TAG = "cst328.touchscreen"; + +static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet +static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less +static const uint16_t CST328_FW_CRC = 0xCACA; // Expected firmware CRC value +static const uint8_t CST328_SYNC_BYTE = 0xAB; // Sync byte used in communication + +static const uint8_t ZERO_BYTE = 0; + +#define I2C_WARN_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGW(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->status_set_warning(format); \ + } \ + } while (0) + +#define I2C_FAIL_ON_ERROR(x, log_tag, format, ...) \ + do { \ + i2c::ErrorCode err_rc_ = (x); \ + if (err_rc_ != i2c::ERROR_OK) { \ + ESP_LOGE(log_tag, "%s(%d): [error %d] " format, __FUNCTION__, __LINE__, err_rc_, ##__VA_ARGS__); \ + this->mark_failed(); \ + return; \ + } \ + } while (0) + +void CST328Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up CST328 Touchscreen..."); + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_BEFORE_RESET_TIMEOUT, [this] { this->reset_device_(); }); + } else { + this->continue_setup_(); + } +} + +void CST328Touchscreen::reset_device_() { + this->reset_pin_->digital_write(false); + delay(5); + this->reset_pin_->digital_write(true); + this->set_timeout(CST328_TRANSITION_TIMEOUT, [this] { this->continue_setup_(); }); +} + +void CST328Touchscreen::continue_setup_() { + ESP_LOGV(TAG, "Continuing CST328 setup..."); + + uint8_t data_byte{0}; + uint8_t buf[24]{}; + + I2C_FAIL_ON_ERROR(this->write_register16(CST_WM_DEBUG_INFO, buf, 0), TAG, "Failed to enter debug/info mode"); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_CRC_AND_BOOT_TIME, buf, 4), TAG, + "Failed to read FW CRC and boot time"); + + uint16_t fw_crc = buf[2] + (buf[3] << 8); + if (fw_crc != CST328_FW_CRC) { + ESP_LOGE(TAG, "Error: Firmware CRC mismatch, expected 0x%04X but got 0x%04X", CST328_FW_CRC, fw_crc); + this->mark_failed(); + return; + } + + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_CHIP_TYPE_AND_PROJECT_ID, buf, 4), TAG, + "Failed to read chip and project ID"); + + this->chip_id_ = buf[2] + (buf[3] << 8); + this->project_id_ = buf[0] + (buf[1] << 8); + ESP_LOGD(TAG, "Chip ID %X, project ID %X", this->chip_id_, this->project_id_); + I2C_FAIL_ON_ERROR(this->read_register16(CST_REG_FW_REVISION, buf, 4), TAG, "Failed to read FW version"); + + this->fw_ver_major_ = buf[3]; + this->fw_ver_minor_ = buf[2]; + this->fw_build_ = buf[0] + (buf[1] << 8); + ESP_LOGV(TAG, "FW version %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + + if (i2c::ERROR_OK == this->read_register16(CST_REG_X_Y_RESOLUTION, buf, 4)) { + this->x_raw_max_ = buf[0] + (buf[1] << 8); + this->y_raw_max_ = buf[2] + (buf[3] << 8); + } else { + this->x_raw_max_ = this->display_->get_native_width(); + this->y_raw_max_ = this->display_->get_native_height(); + } + + I2C_WARN_ON_ERROR(this->write_register16(CST_WM_NORMAL, buf, 0), TAG, "Failed to enter normal mode"); + I2C_WARN_ON_ERROR(this->read_register16(CST_REG_TOUCH_INFORMATION, &data_byte, 1), TAG, "Failed to read sync"); + I2C_WARN_ON_ERROR(this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1), TAG, + "Failed to write sync"); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + this->setup_complete_ = true; + ESP_LOGV(TAG, "CST328 setup complete"); +} + +void CST328Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "CST328 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + ESP_LOGCONFIG(TAG, " Chip ID: 0x%04X, Project ID: 0x%04X", this->chip_id_, this->project_id_); + ESP_LOGCONFIG(TAG, " FW version: %d.%d.%d", this->fw_ver_major_, this->fw_ver_minor_, this->fw_build_); + ESP_LOGCONFIG(TAG, " X/Y resolution: %d/%d", this->x_raw_max_, this->y_raw_max_); +} + +void CST328Touchscreen::update_button_state_(bool state) { + if (this->button_touched_ == state) { + return; + } + this->button_touched_ = state; + for (auto *listener : this->button_listeners_) { + listener->update_button(state); + } +} + +void CST328Touchscreen::update_touches() { + if (!this->setup_complete_) { + this->skip_update_ = true; + return; + } + + uint8_t touch_data[CST328_TOUCH_DATA_SIZE]; + + this->status_clear_warning(); + + if (i2c::ERROR_OK != this->read_register16(CST_REG_TOUCH_INFORMATION, touch_data, CST328_TOUCH_DATA_SIZE)) { + ESP_LOGW(TAG, "Failed to read touch data"); + this->status_set_warning(); + this->skip_update_ = true; + return; + } + + uint8_t touch_cnt = touch_data[CST_REG_FINGER_COUNT_IDX] & 0x0F; + if (touch_cnt == 0 || touch_cnt > CST328_TOUCH_MAX_POINTS) { + this->update_button_state_(false); + } else { + this->update_button_state_(true); + + uint8_t data_idx = 0; + for (uint8_t i = 0; i < touch_cnt; i++) { + uint8_t id = touch_data[data_idx] >> 4; + int16_t x = (touch_data[data_idx + 1] << 4) | ((touch_data[data_idx + 3] >> 4) & 0x0F); + int16_t y = (touch_data[data_idx + 2] << 4) | (touch_data[data_idx + 3] & 0x0F); + int16_t z = touch_data[data_idx + 4]; + + this->add_raw_touch_position_(id, x, y, z); + data_idx += (i == 0) ? 7 : 5; + } + } + + bool cleanup_error = false; + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_FINGER_NUMBER, &ZERO_BYTE, 1)); + cleanup_error |= (i2c::ERROR_OK != this->write_register16(CST_REG_TOUCH_INFORMATION, &CST328_SYNC_BYTE, 1)); + + if (cleanup_error) { + ESP_LOGW(TAG, "Failed to clean up touch registers"); + } +} + +} // namespace esphome::cst328 diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.h b/esphome/components/cst328/touchscreen/cst328_touchscreen.h new file mode 100644 index 0000000000..234ec6eee0 --- /dev/null +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.h @@ -0,0 +1,61 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::cst328 { + +static const uint8_t CST328_TOUCH_MAX_POINTS = 5; +static const uint8_t CST328_TOUCH_DATA_SIZE = CST328_TOUCH_MAX_POINTS * 5 + 2; + +static const uint16_t CST_REG_TOUCH_INFORMATION = 0xD000; +static const uint16_t CST_REG_TOUCH_FINGER_NUMBER = 0xD005; + +static const uint16_t CST_REG_FINGER_COUNT_IDX = CST_REG_TOUCH_FINGER_NUMBER - CST_REG_TOUCH_INFORMATION; + +static const uint16_t CST_REG_X_Y_RESOLUTION = 0xD1F8; +static const uint16_t CST_REG_FW_CRC_AND_BOOT_TIME = 0xD1FC; +static const uint16_t CST_REG_CHIP_TYPE_AND_PROJECT_ID = 0xD204; +static const uint16_t CST_REG_FW_REVISION = 0xD208; + +static const uint16_t CST_WM_DEBUG_INFO = 0xD101; +static const uint16_t CST_WM_NORMAL = 0xD109; + +class CST328ButtonListener { + public: + virtual void update_button(bool state) = 0; +}; + +class CST328Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void register_button_listener(CST328ButtonListener *listener) { this->button_listeners_.push_back(listener); } + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void reset_device_(); + void continue_setup_(); + void update_button_state_(bool state); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + + std::vector button_listeners_; + bool button_touched_{}; + + uint16_t chip_id_{}; + uint16_t project_id_{}; + uint8_t fw_ver_major_{}; + uint8_t fw_ver_minor_{}; + uint16_t fw_build_{}; + + bool setup_complete_{}; +}; + +} // namespace esphome::cst328 diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 19c169c3ec..84b561c734 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -37,7 +37,7 @@ class CST816ButtonListener { virtual void update_button(bool state) = 0; }; -class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class CST816Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void update_touches() override; diff --git a/esphome/components/cst9220/__init__.py b/esphome/components/cst9220/__init__.py new file mode 100644 index 0000000000..f97c8944ef --- /dev/null +++ b/esphome/components/cst9220/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c"] + +cst9220_ns = cg.esphome_ns.namespace("cst9220") diff --git a/esphome/components/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py new file mode 100644 index 0000000000..6d8fc5e2f6 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -0,0 +1,36 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import cst9220_ns + +CST9220Touchscreen = cst9220_ns.class_( + "CST9220Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("100ms") + .extend( + { + cv.GenerateID(): cv.declare_id(CST9220Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(i2c.i2c_device_schema(0x5A)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp new file mode 100644 index 0000000000..366b1846d7 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.cpp @@ -0,0 +1,141 @@ +#include "cst9220_touchscreen.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::cst9220 { + +void CST9220Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + // Wait for the controller to leave its bootloader before talking to it. + this->set_timeout(30, [this] { this->continue_setup_(); }); +} + +void CST9220Touchscreen::continue_setup_() { + uint8_t buffer[4]; + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Enter command mode so the configuration registers can be read. + if (this->write_register16(REG_CMD_MODE, buffer, 0) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to enter command mode")); + this->mark_failed(); + return; + } + delay(10); + + // The firmware check code confirms that valid firmware is loaded. + if (this->read_register16(REG_CHECKCODE, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read check code")); + this->mark_failed(); + return; + } + uint32_t checkcode = encode_uint32(buffer[3], buffer[2], buffer[1], buffer[0]); + if ((checkcode & 0xFFFF0000) != 0xCACA0000) { + ESP_LOGE(TAG, "Invalid firmware check code: 0x%08" PRIX32, checkcode); + this->status_set_error(LOG_STR("Invalid firmware check code")); + this->mark_failed(); + return; + } + + // Read the panel resolution unless the user supplied calibration values. + if (this->read_register16(REG_RESOLUTION, buffer, 4) == i2c::ERROR_OK) { + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = encode_uint16(buffer[1], buffer[0]); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = encode_uint16(buffer[3], buffer[2]); + } + + // Read the chip type and project id and validate the controller. + if (this->read_register16(REG_CHIP_INFO, buffer, 4) != i2c::ERROR_OK) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + this->chip_id_ = encode_uint16(buffer[3], buffer[2]); + this->project_id_ = encode_uint16(buffer[1], buffer[0]); + if (this->chip_id_ != CST9220_CHIP_ID && this->chip_id_ != CST9217_CHIP_ID) { + ESP_LOGE(TAG, "Unknown chip ID: 0x%04X", this->chip_id_); + this->status_set_error(LOG_STR("Unknown chip ID")); + this->mark_failed(); + return; + } + + // Fall back to the display dimensions if the resolution read failed. + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = this->display_->get_native_width(); + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = this->display_->get_native_height(); + + this->setup_complete_ = true; +} + +void CST9220Touchscreen::update_touches() { + if (!this->setup_complete_) + return; + uint8_t data[CST9220_DATA_LENGTH]; + // Only an actual I2C failure should skip the update; a successful read with no + // touches is a real "all fingers lifted" state that must flow through so the + // base class can generate the release event. + if (this->read_register16(REG_TOUCH_DATA, data, sizeof(data)) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + return; + } + this->status_clear_warning(); + + // Acknowledge the report so the controller can prepare the next one. + uint8_t ack = TOUCH_ACK; + this->write_register16(REG_TOUCH_DATA, &ack, 1); + + // A valid report carries the ACK marker at offset 6; offset 0 holds the first + // point and must be neither the ACK marker nor empty. Anything else means no + // valid touch data this cycle, which we report as zero touches (not a skip). + if (data[0] == TOUCH_ACK || data[0] == 0x00 || data[6] != TOUCH_ACK) + return; + + uint8_t num_touches = data[5] & 0x7F; + if (num_touches > CST9220_MAX_TOUCHES) + num_touches = CST9220_MAX_TOUCHES; + + for (uint8_t i = 0; i < num_touches; i++) { + // The first point starts at offset 0; subsequent points are offset by the + // two status bytes that follow it. + const uint8_t *p = data + i * 5 + (i == 0 ? 0 : 2); + uint8_t id = p[0] >> 4; + uint8_t event = p[0] & 0x0F; + if (event != TOUCH_EVENT_DOWN) + continue; + // p[3] is shared: high nibble holds the X LSBs, low nibble the Y LSBs. + uint16_t x = (p[1] << 4) | (p[3] >> 4); + uint16_t y = (p[2] << 4) | (p[3] & 0x0F); + ESP_LOGV(TAG, "Read touch %d: %d/%d", id, x, y); + this->add_raw_touch_position_(id, x, y); + } +} + +void CST9220Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "CST9220 Touchscreen:\n" + " Chip ID: 0x%04X\n" + " Project ID: 0x%04X\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->chip_id_, this->project_id_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, + this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::cst9220 diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h new file mode 100644 index 0000000000..17050e2429 --- /dev/null +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::cst9220 { + +static const char *const TAG = "cst9220.touchscreen"; + +// The CST92xx family uses 16-bit (big-endian) register addresses. +static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report +static const uint16_t REG_CMD_MODE = 0xD101; // enter command mode +static const uint16_t REG_CHECKCODE = 0xD1FC; // firmware check code +static const uint16_t REG_RESOLUTION = 0xD1F8; // panel resolution +static const uint16_t REG_CHIP_INFO = 0xD204; // chip type + project id + +static const uint8_t TOUCH_ACK = 0xAB; +static const uint8_t TOUCH_EVENT_DOWN = 0x06; + +static const uint16_t CST9220_CHIP_ID = 0x9220; +static const uint16_t CST9217_CHIP_ID = 0x9217; + +// Maximum simultaneous touch points reported by the family. +static const uint8_t CST9220_MAX_TOUCHES = 5; +// Report layout: 5 bytes per touch point plus 5 bytes of status/ack overhead. +static const size_t CST9220_DATA_LENGTH = CST9220_MAX_TOUCHES * 5 + 5; + +class CST9220Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + void continue_setup_(); + + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{}; + uint16_t chip_id_{}; + uint16_t project_id_{}; + bool setup_complete_{}; +}; + +} // namespace esphome::cst9220 diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.h b/esphome/components/ct_clamp/ct_clamp_sensor.h index 2055edbd3e..ae77c04390 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.h +++ b/esphome/components/ct_clamp/ct_clamp_sensor.h @@ -7,7 +7,7 @@ namespace esphome::ct_clamp { -class CTClampSensor : public sensor::Sensor, public PollingComponent { +class CTClampSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index 5a499d54a4..d15b310a37 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -39,7 +39,7 @@ void CurrentBasedCover::control(const CoverCall &call) { auto opt_pos = call.get_position(); if (opt_pos.has_value()) { auto pos = *opt_pos; - if (fabsf(this->position - pos) < 0.01) { + if (fabsf(this->position - pos) < 0.01f) { // already at target } else { auto op = pos < this->position ? COVER_OPERATION_CLOSING : COVER_OPERATION_OPENING; diff --git a/esphome/components/current_based/current_based_cover.h b/esphome/components/current_based/current_based_cover.h index 531f8d5a4f..41dd9962b7 100644 --- a/esphome/components/current_based/current_based_cover.h +++ b/esphome/components/current_based/current_based_cover.h @@ -8,7 +8,7 @@ namespace esphome::current_based { -class CurrentBasedCover : public cover::Cover, public Component { +class CurrentBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/cwww/cwww_light_output.h b/esphome/components/cwww/cwww_light_output.h index 6eed8de7cc..aea844008f 100644 --- a/esphome/components/cwww/cwww_light_output.h +++ b/esphome/components/cwww/cwww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::cwww { -class CWWWLightOutput : public light::LightOutput { +class CWWWLightOutput final : public light::LightOutput { public: void set_cold_white(output::FloatOutput *cold_white) { cold_white_ = cold_white; } void set_warm_white(output::FloatOutput *warm_white) { warm_white_ = warm_white; } diff --git a/esphome/components/dac7678/dac7678_output.h b/esphome/components/dac7678/dac7678_output.h index a017325939..00021e947f 100644 --- a/esphome/components/dac7678/dac7678_output.h +++ b/esphome/components/dac7678/dac7678_output.h @@ -9,7 +9,7 @@ namespace esphome::dac7678 { class DAC7678Output; -class DAC7678Channel : public output::FloatOutput, public Parented { +class DAC7678Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -24,7 +24,7 @@ class DAC7678Channel : public output::FloatOutput, public Parentedfahrenheit_) { temperature = (uint8_t) roundf( - clamp(((this->target_temperature * 1.8) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); + clamp(((this->target_temperature * 1.8f) + 32), DAIKIN_BRC_TEMP_MIN_F, DAIKIN_BRC_TEMP_MAX_F)); } else { temperature = ((uint8_t) roundf(this->target_temperature) - 9) << 1; } diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index 4b5c679f7f..c963f72ab6 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -48,7 +48,7 @@ const uint8_t DAIKIN_BRC_PREAMBLE_SIZE = 7; // Transmit Frame size - includes a preamble const uint8_t DAIKIN_BRC_TRANSMIT_FRAME_SIZE = DAIKIN_BRC_PREAMBLE_SIZE + DAIKIN_BRC_STATE_FRAME_SIZE; -class DaikinBrcClimate : public climate_ir::ClimateIR { +class DaikinBrcClimate final : public climate_ir::ClimateIR { public: DaikinBrcClimate() : climate_ir::ClimateIR(DAIKIN_BRC_TEMP_MIN_C, DAIKIN_BRC_TEMP_MAX_C, 0.5f, true, true, diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index 35488eab03..ab4a8c458f 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -138,7 +138,7 @@ float DallasTemperatureSensor::get_temp_c_() { if (this->scratch_pad_[7] == 0) { return NAN; } - return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25; + return (temp >> 1) + (this->scratch_pad_[7] - this->scratch_pad_[6]) / float(this->scratch_pad_[7]) - 0.25f; } switch (this->resolution_) { case 9: diff --git a/esphome/components/dallas_temp/dallas_temp.h b/esphome/components/dallas_temp/dallas_temp.h index 3f7b447fc7..0c8f613a2e 100644 --- a/esphome/components/dallas_temp/dallas_temp.h +++ b/esphome/components/dallas_temp/dallas_temp.h @@ -6,7 +6,7 @@ namespace esphome::dallas_temp { -class DallasTemperatureSensor : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { +class DallasTemperatureSensor final : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { public: void setup() override; void update() override; diff --git a/esphome/components/daly_bms/daly_bms.h b/esphome/components/daly_bms/daly_bms.h index 6dcc66b5ee..c28099c3e1 100644 --- a/esphome/components/daly_bms/daly_bms.h +++ b/esphome/components/daly_bms/daly_bms.h @@ -17,7 +17,7 @@ namespace esphome::daly_bms { -class DalyBmsComponent : public PollingComponent, public uart::UARTDevice { +class DalyBmsComponent final : public PollingComponent, public uart::UARTDevice { public: DalyBmsComponent() = default; diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 30b3394165..000db307b9 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -77,7 +77,7 @@ async def to_code(config): url = config[CONF_PACKAGE_IMPORT_URL] if config[CONF_IMPORT_FULL_CONFIG]: url += "?full_config" - cg.add(dashboard_import_ns.set_package_import_url(url)) + cg.add(dashboard_import_ns.set_package_import_url(cg.FlashStringLiteral(url))) def import_config( @@ -92,7 +92,6 @@ def import_config( """Materialise a dashboard-imported device's YAML on disk. Used by: - - esphome.dashboard (legacy dashboard) - device-builder (esphome/device-builder) — called from the ``devices/import`` WS handler to seed the YAML for an adopted factory firmware. Coordinate before changing the kwargs or the diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index f553adf273..adc01cc0a8 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -2,9 +2,10 @@ namespace esphome::dashboard_import { -static const char *g_package_import_url = ""; // NOLINT +static const char EMPTY_URL[] PROGMEM = ""; // NOLINT +static ProgmemStr g_package_import_url = reinterpret_cast(EMPTY_URL); // NOLINT -const char *get_package_import_url() { return g_package_import_url; } -void set_package_import_url(const char *url) { g_package_import_url = url; } +ProgmemStr get_package_import_url() { return g_package_import_url; } +void set_package_import_url(ProgmemStr url) { g_package_import_url = url; } } // namespace esphome::dashboard_import diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index 19f69b8546..166fd8b7be 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,8 +1,10 @@ #pragma once +#include "esphome/core/progmem.h" + namespace esphome::dashboard_import { -const char *get_package_import_url(); -void set_package_import_url(const char *url); +ProgmemStr get_package_import_url(); +void set_package_import_url(ProgmemStr url); } // namespace esphome::dashboard_import diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 8233e809a1..9b86c12228 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -96,7 +96,7 @@ class DateCall { optional day_; }; -template class DateSetAction : public Action, public Parented { +template class DateSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, date) diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index 6c0a33c842..f99debb692 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,7 +31,7 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger : public Trigger { +class DateTimeStateTrigger final : public Trigger { public: explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index b1b8a77846..159e4ccc6f 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,7 +121,7 @@ class DateTimeCall { optional second_; }; -template class DateTimeSetAction : public Action, public Parented { +template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) @@ -136,7 +136,7 @@ template class DateTimeSetAction : public Action, public }; #ifdef USE_TIME -class OnDateTimeTrigger : public Trigger<>, public Component, public Parented { +class OnDateTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 3f224684bb..643f4bd176 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,7 +98,7 @@ class TimeCall { optional second_; }; -template class TimeSetAction : public Action, public Parented { +template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) @@ -113,7 +113,7 @@ template class TimeSetAction : public Action, public Pare }; #ifdef USE_TIME -class OnTimeTrigger : public Trigger<>, public Component, public Parented { +class OnTimeTrigger final : public Trigger<>, public Component, public Parented { public: void loop() override; diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index dc032f442e..3e94d04f21 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -70,7 +70,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "debug_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "debug_host.cpp": {PlatformFramework.HOST_NATIVE}, - "debug_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "debug_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "debug_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 871b7cfd25..20798cf600 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -21,7 +21,7 @@ static constexpr size_t WAKEUP_CAUSE_BUFFER_SIZE = 128; // buf_append_printf is now provided by esphome/core/helpers.h -class DebugComponent : public PollingComponent { +class DebugComponent final : public PollingComponent { public: void loop() override; void update() override; diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2.cpp similarity index 93% rename from esphome/components/debug/debug_rp2040.cpp rename to esphome/components/debug/debug_rp2.cpp index adc23dbf51..ba6081963f 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,5 +1,5 @@ #include "debug_component.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" #include "esphome/core/log.h" #include @@ -9,8 +9,8 @@ #else #include #endif -#ifdef USE_RP2040_CRASH_HANDLER -#include "esphome/components/rp2040/crash_handler.h" +#ifdef USE_RP2_CRASH_HANDLER +#include "esphome/components/rp2/crash_handler.h" #endif namespace esphome::debug { @@ -41,8 +41,8 @@ const char *DebugComponent::get_reset_reason_(std::span class PreventDeepSleepAction; * and set_run_duration, then set how long the deep sleep should last using set_sleep_duration and optionally * on the ESP32 set_wakeup_pin. */ -class DeepSleepComponent : public Component { +class DeepSleepComponent final : public Component { public: /// Set the duration in ms the component should sleep once it's in deep sleep mode. void set_sleep_duration(uint32_t time_ms); @@ -96,7 +96,8 @@ class DeepSleepComponent : public Component { #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void set_touch_wakeup(bool touch_wakeup); #endif @@ -161,7 +162,7 @@ class DeepSleepComponent : public Component { extern bool global_has_deep_sleep; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class EnterDeepSleepAction : public Action { +template class EnterDeepSleepAction final : public Action { public: EnterDeepSleepAction(DeepSleepComponent *deep_sleep) : deep_sleep_(deep_sleep) {} TEMPLATABLE_VALUE(uint32_t, sleep_duration); @@ -233,12 +234,13 @@ template class EnterDeepSleepAction : public Action { #endif }; -template class PreventDeepSleepAction : public Action, public Parented { +template +class PreventDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } }; -template class AllowDeepSleepAction : public Action, public Parented { +template class AllowDeepSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } }; diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index c905b8fcbc..7cb8e53efd 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::deep_sleep { // | ESP32-S3 | ✓ | ✓ | ✓ | | // | ESP32-C2 | | | | ✓ | // | ESP32-C3 | | | | ✓ | -// | ESP32-C5 | | (✓) | | (✓) | +// | ESP32-C5 | | ✓ | | ✓ | // | ESP32-C6 | | ✓ | | ✓ | // | ESP32-C61 | | ✓ | | ✓ | // | ESP32-H2 | | ✓ | | | @@ -56,7 +56,8 @@ void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wa #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif @@ -99,7 +100,8 @@ void DeepSleepComponent::deep_sleep_() { // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -122,9 +124,9 @@ void DeepSleepComponent::deep_sleep_() { } #endif - // GPIO wakeup - C2, C3, C6, C61 only -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32C61) + // GPIO wakeup - C2, C3, C5, C6, C61 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); // Make sure GPIO is in input mode, not all RTC GPIO pins are input by default @@ -154,7 +156,8 @@ void DeepSleepComponent::deep_sleep_() { // Touch wakeup - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { esp_sleep_enable_touchpad_wakeup(); esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index f77b73cd58..cadf7bf42d 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -1,13 +1,36 @@ #include "deep_sleep_component.h" #ifdef USE_ZEPHYR +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/wake.h" #include +#include namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and +// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a +// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed +// it at least this often while waiting so it does not reset the device. +static const uint32_t WDT_FEED_INTERVAL_MS = 1000; + +static bool wakeable_delay_feed_wdt(uint32_t ms) { + while (ms > 0) { + const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS); + esphome::internal::wakeable_delay(step); + esphome::arch_feed_wdt(); + if (esphome::wake_request_take()) { + return true; + } + if (ms != UINT32_MAX) { + ms -= step; + } + } + return false; +} + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} @@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {} bool DeepSleepComponent::prepare_to_sleep_() { return true; } void DeepSleepComponent::deep_sleep_() { + bool woke = false; if (this->sleep_duration_.has_value()) { - esphome::internal::wakeable_delay(static_cast(*this->sleep_duration_ / 1000)); + woke = wakeable_delay_feed_wdt(static_cast(*this->sleep_duration_ / 1000)); } else { #ifndef USE_ZIGBEE // the device can be woken up through one of the following signals: @@ -29,10 +53,9 @@ void DeepSleepComponent::deep_sleep_() { // The system is reset when it wakes up from System OFF mode. sys_poweroff(); #else - esphome::internal::wakeable_delay(UINT32_MAX); + woke = wakeable_delay_feed_wdt(UINT32_MAX); #endif } - const bool woke = esphome::wake_request_take(); if (woke) { ESP_LOGD(TAG, "Woken up by another thread"); } else { diff --git a/esphome/components/delonghi/delonghi.h b/esphome/components/delonghi/delonghi.h index c2fbc36b4f..aee7ceedda 100644 --- a/esphome/components/delonghi/delonghi.h +++ b/esphome/components/delonghi/delonghi.h @@ -39,7 +39,7 @@ const uint32_t DELONGHI_ZERO_SPACE = 670; // State Frame size const uint8_t DELONGHI_STATE_FRAME_SIZE = 8; -class DelonghiClimate : public climate_ir::ClimateIR { +class DelonghiClimate final : public climate_ir::ClimateIR { public: DelonghiClimate() : climate_ir::ClimateIR(DELONGHI_TEMP_MIN, DELONGHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 7aaf3219cf..e85d2a17ba 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -13,7 +13,7 @@ enum class DemoAlarmControlPanelType { TYPE_3, }; -class DemoAlarmControlPanel : public AlarmControlPanel, public Component { +class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { public: void setup() override {} diff --git a/esphome/components/demo/demo_binary_sensor.h b/esphome/components/demo/demo_binary_sensor.h index 4bc3737d5a..6a98a6781b 100644 --- a/esphome/components/demo/demo_binary_sensor.h +++ b/esphome/components/demo/demo_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class DemoBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void setup() override { this->publish_initial_state(false); } void update() override { diff --git a/esphome/components/demo/demo_button.h b/esphome/components/demo/demo_button.h index a0ed92d3d8..907136cfc6 100644 --- a/esphome/components/demo/demo_button.h +++ b/esphome/components/demo/demo_button.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoButton : public button::Button { +class DemoButton final : public button::Button { protected: void press_action() override {} }; diff --git a/esphome/components/demo/demo_climate.h b/esphome/components/demo/demo_climate.h index d0cd2d553d..20affb909f 100644 --- a/esphome/components/demo/demo_climate.h +++ b/esphome/components/demo/demo_climate.h @@ -11,7 +11,7 @@ enum class DemoClimateType { TYPE_3, }; -class DemoClimate : public climate::Climate, public Component { +class DemoClimate final : public climate::Climate, public Component { public: void set_type(DemoClimateType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index c1597a7565..aa12c885f4 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -12,7 +12,7 @@ enum class DemoCoverType { TYPE_4, }; -class DemoCover : public cover::Cover, public Component { +class DemoCover final : public cover::Cover, public Component { public: void set_type(DemoCoverType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_date.h b/esphome/components/demo/demo_date.h index 5a868342cd..f724c82435 100644 --- a/esphome/components/demo/demo_date.h +++ b/esphome/components/demo/demo_date.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDate : public datetime::DateEntity, public Component { +class DemoDate final : public datetime::DateEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_datetime.h b/esphome/components/demo/demo_datetime.h index 84869d1a9f..363592c554 100644 --- a/esphome/components/demo/demo_datetime.h +++ b/esphome/components/demo/demo_datetime.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoDateTime : public datetime::DateTimeEntity, public Component { +class DemoDateTime final : public datetime::DateTimeEntity, public Component { public: void setup() override { this->year_ = 2038; diff --git a/esphome/components/demo/demo_fan.h b/esphome/components/demo/demo_fan.h index 2e2fbce7d6..be44c06ca2 100644 --- a/esphome/components/demo/demo_fan.h +++ b/esphome/components/demo/demo_fan.h @@ -12,7 +12,7 @@ enum class DemoFanType { TYPE_4, }; -class DemoFan : public fan::Fan, public Component { +class DemoFan final : public fan::Fan, public Component { public: void set_type(DemoFanType type) { type_ = type; } fan::FanTraits get_traits() override { diff --git a/esphome/components/demo/demo_light.h b/esphome/components/demo/demo_light.h index 071adb0831..4a48a1796e 100644 --- a/esphome/components/demo/demo_light.h +++ b/esphome/components/demo/demo_light.h @@ -22,7 +22,7 @@ enum class DemoLightType { TYPE_7, }; -class DemoLight : public light::LightOutput, public Component { +class DemoLight final : public light::LightOutput, public Component { public: void set_type(DemoLightType type) { type_ = type; } light::LightTraits get_traits() override { diff --git a/esphome/components/demo/demo_lock.h b/esphome/components/demo/demo_lock.h index 85c1c238ef..473fe1a68e 100644 --- a/esphome/components/demo/demo_lock.h +++ b/esphome/components/demo/demo_lock.h @@ -4,7 +4,7 @@ namespace esphome::demo { -class DemoLock : public lock::Lock { +class DemoLock final : public lock::Lock { protected: void control(const lock::LockCall &call) override { auto state = call.get_state(); diff --git a/esphome/components/demo/demo_number.h b/esphome/components/demo/demo_number.h index 0059cdc2ee..f66aef1aff 100644 --- a/esphome/components/demo/demo_number.h +++ b/esphome/components/demo/demo_number.h @@ -11,7 +11,7 @@ enum class DemoNumberType { TYPE_3, }; -class DemoNumber : public number::Number, public Component { +class DemoNumber final : public number::Number, public Component { public: void set_type(DemoNumberType type) { type_ = type; } void setup() override { diff --git a/esphome/components/demo/demo_select.h b/esphome/components/demo/demo_select.h index 2ecb37db99..de57f2f024 100644 --- a/esphome/components/demo/demo_select.h +++ b/esphome/components/demo/demo_select.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoSelect : public select::Select, public Component { +class DemoSelect final : public select::Select, public Component { protected: void control(size_t index) override { this->publish_state(index); } }; diff --git a/esphome/components/demo/demo_sensor.h b/esphome/components/demo/demo_sensor.h index 867115f21b..ff2163776c 100644 --- a/esphome/components/demo/demo_sensor.h +++ b/esphome/components/demo/demo_sensor.h @@ -6,7 +6,7 @@ namespace esphome::demo { -class DemoSensor : public sensor::Sensor, public PollingComponent { +class DemoSensor final : public sensor::Sensor, public PollingComponent { public: void update() override { float val = random_float(); @@ -15,7 +15,7 @@ class DemoSensor : public sensor::Sensor, public PollingComponent { float base = std::isnan(this->state) ? 0.0f : this->state; this->publish_state(base + val * 10); } else { - if (val < 0.1) { + if (val < 0.1f) { this->publish_state(NAN); } else { this->publish_state(val * 100); diff --git a/esphome/components/demo/demo_switch.h b/esphome/components/demo/demo_switch.h index b2d6e52c67..dea975a770 100644 --- a/esphome/components/demo/demo_switch.h +++ b/esphome/components/demo/demo_switch.h @@ -6,10 +6,10 @@ namespace esphome::demo { -class DemoSwitch : public switch_::Switch, public Component { +class DemoSwitch final : public switch_::Switch, public Component { public: void setup() override { - bool initial = random_float() < 0.5; + bool initial = random_float() < 0.5f; this->publish_state(initial); } diff --git a/esphome/components/demo/demo_text.h b/esphome/components/demo/demo_text.h index 56376c8c42..66dd5bc3eb 100644 --- a/esphome/components/demo/demo_text.h +++ b/esphome/components/demo/demo_text.h @@ -5,7 +5,7 @@ namespace esphome::demo { -class DemoText : public text::Text, public Component { +class DemoText final : public text::Text, public Component { public: void setup() override { this->publish_state("I am a text entity"); } diff --git a/esphome/components/demo/demo_text_sensor.h b/esphome/components/demo/demo_text_sensor.h index 03852a1e7f..8eaa6c6b46 100644 --- a/esphome/components/demo/demo_text_sensor.h +++ b/esphome/components/demo/demo_text_sensor.h @@ -6,13 +6,13 @@ namespace esphome::demo { -class DemoTextSensor : public text_sensor::TextSensor, public PollingComponent { +class DemoTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void update() override { float val = random_float(); - if (val < 0.33) { + if (val < 0.33f) { this->publish_state("foo"); - } else if (val < 0.66) { + } else if (val < 0.66f) { this->publish_state("bar"); } else { this->publish_state("foobar"); diff --git a/esphome/components/demo/demo_time.h b/esphome/components/demo/demo_time.h index f94678fae4..90384b3216 100644 --- a/esphome/components/demo/demo_time.h +++ b/esphome/components/demo/demo_time.h @@ -9,7 +9,7 @@ namespace esphome::demo { -class DemoTime : public datetime::TimeEntity, public Component { +class DemoTime final : public datetime::TimeEntity, public Component { public: void setup() override { this->hour_ = 3; diff --git a/esphome/components/demo/demo_valve.h b/esphome/components/demo/demo_valve.h index 3f1342959a..22183b75e8 100644 --- a/esphome/components/demo/demo_valve.h +++ b/esphome/components/demo/demo_valve.h @@ -9,7 +9,7 @@ enum class DemoValveType { TYPE_2, }; -class DemoValve : public valve::Valve { +class DemoValve final : public valve::Valve { public: valve::ValveTraits get_traits() override { valve::ValveTraits traits; diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h index 833c50fba2..0e97b22a04 100644 --- a/esphome/components/dew_point/dew_point.h +++ b/esphome/components/dew_point/dew_point.h @@ -5,7 +5,7 @@ namespace esphome::dew_point { -class DewPointComponent : public Component, public sensor::Sensor { +class DewPointComponent final : public Component, public sensor::Sensor { 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/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index 5936a06b60..1db6b394c5 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -24,7 +24,7 @@ enum Device { // See the datasheet here: // https://github.com/DFRobot/DFRobotDFPlayerMini/blob/master/doc/FN-M16P%2BEmbedded%2BMP3%2BAudio%2BModule%2BDatasheet.pdf -class DFPlayer : public uart::UARTDevice, public Component { +class DFPlayer final : public uart::UARTDevice, public Component { public: void loop() override; @@ -82,7 +82,7 @@ class DFPlayer : public uart::UARTDevice, public Component { DFPLAYER_SIMPLE_ACTION(NextAction, next) DFPLAYER_SIMPLE_ACTION(PreviousAction, previous) -template class PlayMp3Action : public Action, public Parented { +template class PlayMp3Action final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) @@ -92,7 +92,7 @@ template class PlayMp3Action : public Action, public Pare } }; -template class PlayFileAction : public Action, public Parented { +template class PlayFileAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, file) TEMPLATABLE_VALUE(bool, loop) @@ -108,7 +108,7 @@ template class PlayFileAction : public Action, public Par } }; -template class PlayFolderAction : public Action, public Parented { +template class PlayFolderAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, folder) TEMPLATABLE_VALUE(uint16_t, file) @@ -126,7 +126,7 @@ template class PlayFolderAction : public Action, public P } }; -template class SetDeviceAction : public Action, public Parented { +template class SetDeviceAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(Device, device) @@ -136,7 +136,7 @@ template class SetDeviceAction : public Action, public Pa } }; -template class SetVolumeAction : public Action, public Parented { +template class SetVolumeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, volume) @@ -146,7 +146,7 @@ template class SetVolumeAction : public Action, public Pa } }; -template class SetEqAction : public Action, public Parented { +template class SetEqAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(EqPreset, eq) @@ -165,7 +165,7 @@ DFPLAYER_SIMPLE_ACTION(RandomAction, random) DFPLAYER_SIMPLE_ACTION(VolumeUpAction, volume_up) DFPLAYER_SIMPLE_ACTION(VolumeDownAction, volume_down) -template class DFPlayerIsPlayingCondition : public Condition, public Parented { +template class DFPlayerIsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; diff --git a/esphome/components/dfrobot_sen0395/automation.h b/esphome/components/dfrobot_sen0395/automation.h index bd91381d47..a5f4c99014 100644 --- a/esphome/components/dfrobot_sen0395/automation.h +++ b/esphome/components/dfrobot_sen0395/automation.h @@ -8,13 +8,13 @@ namespace esphome::dfrobot_sen0395 { template -class DfrobotSen0395ResetAction : public Action, public Parented { +class DfrobotSen0395ResetAction final : public Action, public Parented { public: void play(const Ts &...x) { this->parent_->enqueue(make_unique()); } }; template -class DfrobotSen0395SettingsAction : public Action, public Parented { +class DfrobotSen0395SettingsAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int8_t, factory_reset) TEMPLATABLE_VALUE(int8_t, start_after_power_on) diff --git a/esphome/components/dfrobot_sen0395/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 29ee166f51..570bfef943 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -121,51 +121,51 @@ DetRangeCfgCommand::DetRangeCfgCommand(float min1, float max1, float min2, float this->cmd_ = "detRangeCfg -1 0 0"; } else if (min2 < 0 || max2 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; this->min2_ = min2 = this->max2_ = max2 = this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15, max1 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f", min1 / 0.15f, max1 / 0.15f); this->cmd_ = buf; } else if (min3 < 0 || max3 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; this->min3_ = min3 = this->max3_ = max3 = this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f); this->cmd_ = buf; } else if (min4 < 0 || max4 < 0) { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; this->min4_ = min4 = this->max4_ = max4 = -1; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, min2 / 0.15, - max2 / 0.15, min3 / 0.15, max3 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, min2 / 0.15f, + max2 / 0.15f, min3 / 0.15f, max3 / 0.15f); this->cmd_ = buf; } else { - this->min1_ = min1 = round(min1 / 0.15) * 0.15; - this->max1_ = max1 = round(max1 / 0.15) * 0.15; - this->min2_ = min2 = round(min2 / 0.15) * 0.15; - this->max2_ = max2 = round(max2 / 0.15) * 0.15; - this->min3_ = min3 = round(min3 / 0.15) * 0.15; - this->max3_ = max3 = round(max3 / 0.15) * 0.15; - this->min4_ = min4 = round(min4 / 0.15) * 0.15; - this->max4_ = max4 = round(max4 / 0.15) * 0.15; + this->min1_ = min1 = roundf(min1 / 0.15f) * 0.15f; + this->max1_ = max1 = roundf(max1 / 0.15f) * 0.15f; + this->min2_ = min2 = roundf(min2 / 0.15f) * 0.15f; + this->max2_ = max2 = roundf(max2 / 0.15f) * 0.15f; + this->min3_ = min3 = roundf(min3 / 0.15f) * 0.15f; + this->max3_ = max3 = roundf(max3 / 0.15f) * 0.15f; + this->min4_ = min4 = roundf(min4 / 0.15f) * 0.15f; + this->max4_ = max4 = roundf(max4 / 0.15f) * 0.15f; char buf[72]; // max 72: "detRangeCfg -1 "(15) + 8 * (float(5) + space(1)) + null - snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15, max1 / 0.15, - min2 / 0.15, max2 / 0.15, min3 / 0.15, max3 / 0.15, min4 / 0.15, max4 / 0.15); + snprintf(buf, sizeof(buf), "detRangeCfg -1 %.0f %.0f %.0f %.0f %.0f %.0f %.0f %.0f", min1 / 0.15f, max1 / 0.15f, + min2 / 0.15f, max2 / 0.15f, min3 / 0.15f, max3 / 0.15f, min4 / 0.15f, max4 / 0.15f); this->cmd_ = buf; } diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h index 03e3b6b6ec..448a18a477 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.h @@ -36,7 +36,7 @@ class CircularCommandQueue { std::unique_ptr commands_[COMMAND_QUEUE_SIZE]; }; -class DfrobotSen0395Component : public uart::UARTDevice, public Component { +class DfrobotSen0395Component final : public uart::UARTDevice, public Component { #ifdef USE_SWITCH SUB_SWITCH(sensor_active) SUB_SWITCH(turn_on_led) diff --git a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h index d83734b034..1c2e929b24 100644 --- a/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h +++ b/esphome/components/dfrobot_sen0395/switch/dfrobot_sen0395_switch.h @@ -9,22 +9,22 @@ namespace esphome::dfrobot_sen0395 { class DfrobotSen0395Switch : public switch_::Switch, public Component, public Parented {}; -class Sen0395PowerSwitch : public DfrobotSen0395Switch { +class Sen0395PowerSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395LedSwitch : public DfrobotSen0395Switch { +class Sen0395LedSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395UartPresenceSwitch : public DfrobotSen0395Switch { +class Sen0395UartPresenceSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; -class Sen0395StartAfterBootSwitch : public DfrobotSen0395Switch { +class Sen0395StartAfterBootSwitch final : public DfrobotSen0395Switch { public: void write_state(bool state) override; }; diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 0c535f7cf6..86292e144f 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -18,7 +18,7 @@ enum DHTModel : uint8_t { }; /// Component for reading temperature/humidity measurements from DHT11/DHT22 sensors. -class DHT : public PollingComponent { +class DHT final : public PollingComponent { public: /** Manually select the DHT model. * diff --git a/esphome/components/dht12/dht12.h b/esphome/components/dht12/dht12.h index 5f4f822e70..b835ac1648 100644 --- a/esphome/components/dht12/dht12.h +++ b/esphome/components/dht12/dht12.h @@ -6,7 +6,7 @@ namespace esphome::dht12 { -class DHT12Component : public PollingComponent, public i2c::I2CDevice { +class DHT12Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index b24c099bce..115adf503a 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -42,10 +42,10 @@ void Display::line_at_angle(int x, int y, int angle, int length, Color color) { void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_radius, Color color) { // Calculate start and end points - int x1 = (start_radius * cos(angle * M_PI / 180)) + x; - int y1 = (start_radius * sin(angle * M_PI / 180)) + y; - int x2 = (stop_radius * cos(angle * M_PI / 180)) + x; - int y2 = (stop_radius * sin(angle * M_PI / 180)) + y; + int x1 = (start_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y1 = (start_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; + int x2 = (stop_radius * std::cos(angle * std::numbers::pi_v / 180)) + x; + int y2 = (stop_radius * std::sin(angle * std::numbers::pi_v / 180)) + y; // Draw line this->line(x1, y1, x2, y2, color); @@ -228,7 +228,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int e2max, e2min; progress = std::max(0, std::min(progress, 100)); // 0..100 int draw_progress = progress > 50 ? (100 - progress) : progress; - float tan_a = (progress == 50) ? 65535 : tan(float(draw_progress) * M_PI / 100); // slope + float tan_a = (progress == 50) ? 65535 : tanf(float(draw_progress) * std::numbers::pi_v / 100); // slope do { // outer dots @@ -444,15 +444,15 @@ void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int * // hence we rotate the shape by 270° to orient the polygon up. rotation_degrees += ROTATION_270_DEGREES; // Convert the rotation to radians, easier to use in trigonometrical calculations - float rotation_radians = rotation_degrees * std::numbers::pi / 180; + float rotation_radians = rotation_degrees * std::numbers::pi_v / 180; // A pointy top variation means the first vertex of the polygon is at the top center of the shape, this requires no // additional rotation of the shape. // A flat top variation means the first point of the polygon has to be rotated so that the first edge is horizontal, // this requires to rotate the shape by π/edges radians counter-clockwise so that the first point is located on the // left side of the first horizontal edge. - rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi / edges : 0.0; + rotation_radians -= (variation == VARIATION_FLAT_TOP) ? std::numbers::pi_v / edges : 0.0f; - float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi + rotation_radians; + float vertex_angle = ((float) vertex_id) / edges * 2 * std::numbers::pi_v + rotation_radians; *vertex_x = (int) std::round(std::cos(vertex_angle) * radius) + center_x; *vertex_y = (int) std::round(std::sin(vertex_angle) * radius) + center_y; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 6d0b7acfe8..3a136937f6 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -796,7 +796,7 @@ class Display : public PollingComponent { bool show_test_card_{false}; }; -class DisplayPage { +class DisplayPage final { public: DisplayPage(display_writer_t writer); void show(); @@ -814,7 +814,7 @@ class DisplayPage { DisplayPage *next_{nullptr}; }; -template class DisplayPageShowAction : public Action { +template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) @@ -826,7 +826,7 @@ template class DisplayPageShowAction : public Action { } }; -template class DisplayPageShowNextAction : public Action { +template class DisplayPageShowNextAction final : public Action { public: DisplayPageShowNextAction(Display *buffer) : buffer_(buffer) {} @@ -835,7 +835,7 @@ template class DisplayPageShowNextAction : public Action Display *buffer_; }; -template class DisplayPageShowPrevAction : public Action { +template class DisplayPageShowPrevAction final : public Action { public: DisplayPageShowPrevAction(Display *buffer) : buffer_(buffer) {} @@ -844,7 +844,7 @@ template class DisplayPageShowPrevAction : public Action Display *buffer_; }; -template class DisplayIsDisplayingPageCondition : public Condition { +template class DisplayIsDisplayingPageCondition final : public Condition { public: DisplayIsDisplayingPageCondition(Display *parent) : parent_(parent) {} @@ -856,7 +856,7 @@ template class DisplayIsDisplayingPageCondition : public Conditi DisplayPage *page_; }; -class DisplayOnPageChangeTrigger : public Trigger { +class DisplayOnPageChangeTrigger final : public Trigger { public: explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } void process(DisplayPage *from, DisplayPage *to); diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h index d4f83055d1..be0044ffa4 100644 --- a/esphome/components/display_menu_base/automation.h +++ b/esphome/components/display_menu_base/automation.h @@ -5,7 +5,7 @@ namespace esphome::display_menu_base { -template class UpAction : public Action { +template class UpAction final : public Action { public: explicit UpAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -15,7 +15,7 @@ template class UpAction : public Action { DisplayMenuComponent *menu_; }; -template class DownAction : public Action { +template class DownAction final : public Action { public: explicit DownAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -25,7 +25,7 @@ template class DownAction : public Action { DisplayMenuComponent *menu_; }; -template class LeftAction : public Action { +template class LeftAction final : public Action { public: explicit LeftAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -35,7 +35,7 @@ template class LeftAction : public Action { DisplayMenuComponent *menu_; }; -template class RightAction : public Action { +template class RightAction final : public Action { public: explicit RightAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -45,7 +45,7 @@ template class RightAction : public Action { DisplayMenuComponent *menu_; }; -template class EnterAction : public Action { +template class EnterAction final : public Action { public: explicit EnterAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -55,7 +55,7 @@ template class EnterAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowAction : public Action { +template class ShowAction final : public Action { public: explicit ShowAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -65,7 +65,7 @@ template class ShowAction : public Action { DisplayMenuComponent *menu_; }; -template class HideAction : public Action { +template class HideAction final : public Action { public: explicit HideAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -75,7 +75,7 @@ template class HideAction : public Action { DisplayMenuComponent *menu_; }; -template class ShowMainAction : public Action { +template class ShowMainAction final : public Action { public: explicit ShowMainAction(DisplayMenuComponent *menu) : menu_(menu) {} @@ -84,7 +84,7 @@ template class ShowMainAction : public Action { protected: DisplayMenuComponent *menu_; }; -template class IsActiveCondition : public Condition { +template class IsActiveCondition final : public Condition { public: explicit IsActiveCondition(DisplayMenuComponent *menu) : menu_(menu) {} bool check(const Ts &...x) override { return this->menu_->is_active(); } @@ -93,7 +93,7 @@ template class IsActiveCondition : public Condition { DisplayMenuComponent *menu_; }; -class DisplayMenuOnEnterTrigger : public Trigger { +class DisplayMenuOnEnterTrigger final : public Trigger { public: explicit DisplayMenuOnEnterTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); @@ -103,7 +103,7 @@ class DisplayMenuOnEnterTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnLeaveTrigger : public Trigger { +class DisplayMenuOnLeaveTrigger final : public Trigger { public: explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); @@ -113,7 +113,7 @@ class DisplayMenuOnLeaveTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnValueTrigger : public Trigger { +class DisplayMenuOnValueTrigger final : public Trigger { public: explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); @@ -123,7 +123,7 @@ class DisplayMenuOnValueTrigger : public Trigger { MenuItem *parent_; }; -class DisplayMenuOnNextTrigger : public Trigger { +class DisplayMenuOnNextTrigger final : public Trigger { public: explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); @@ -133,7 +133,7 @@ class DisplayMenuOnNextTrigger : public Trigger { MenuItemCustom *parent_; }; -class DisplayMenuOnPrevTrigger : public Trigger { +class DisplayMenuOnPrevTrigger final : public Trigger { public: explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h index f3c41583f7..d5732377e3 100644 --- a/esphome/components/display_menu_base/menu_item.h +++ b/esphome/components/display_menu_base/menu_item.h @@ -70,7 +70,7 @@ class MenuItem { CallbackManager on_value_callbacks_{}; }; -class MenuItemMenu : public MenuItem { +class MenuItemMenu final : public MenuItem { public: explicit MenuItemMenu() : MenuItem(MENU_ITEM_MENU) {} void add_item(MenuItem *item) { @@ -97,7 +97,7 @@ class MenuItemEditable : public MenuItem { }; #ifdef USE_SELECT -class MenuItemSelect : public MenuItemEditable { +class MenuItemSelect final : public MenuItemEditable { public: explicit MenuItemSelect() : MenuItemEditable(MENU_ITEM_SELECT) {} void set_select_variable(select::Select *var) { this->select_var_ = var; } @@ -114,7 +114,7 @@ class MenuItemSelect : public MenuItemEditable { #endif #ifdef USE_NUMBER -class MenuItemNumber : public MenuItemEditable { +class MenuItemNumber final : public MenuItemEditable { public: explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {} void set_number_variable(number::Number *var) { this->number_var_ = var; } @@ -135,7 +135,7 @@ class MenuItemNumber : public MenuItemEditable { #endif #ifdef USE_SWITCH -class MenuItemSwitch : public MenuItemEditable { +class MenuItemSwitch final : public MenuItemEditable { public: explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {} void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; } @@ -158,7 +158,7 @@ class MenuItemSwitch : public MenuItemEditable { }; #endif -class MenuItemCommand : public MenuItem { +class MenuItemCommand final : public MenuItem { public: explicit MenuItemCommand() : MenuItem(MENU_ITEM_COMMAND) {} @@ -166,7 +166,7 @@ class MenuItemCommand : public MenuItem { bool select_prev() override; }; -class MenuItemCustom : public MenuItemEditable { +class MenuItemCustom final : public MenuItemEditable { public: explicit MenuItemCustom() : MenuItemEditable(MENU_ITEM_CUSTOM) {} template void add_on_next_callback(F &&cb) { this->on_next_callbacks_.add(std::forward(cb)); } diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 7094699b0b..b747f73a14 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -136,12 +136,8 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), - cv.Optional(CONF_AUTH_KEY): lambda value: cv.bind_key( - value, name="Authentication key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), + cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, cv.Optional(CONF_PROVIDER): cv.string, diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index cdc53d5685..fc4721843f 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -98,7 +98,7 @@ struct CustomPattern { std::optional> default_obis; }; -class DlmsMeterComponent : public Component, public uart::UARTDevice { +class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, std::optional> decryption_key, diff --git a/esphome/components/dps310/dps310.h b/esphome/components/dps310/dps310.h index 09143bf6b8..4dd23985d7 100644 --- a/esphome/components/dps310/dps310.h +++ b/esphome/components/dps310/dps310.h @@ -35,7 +35,7 @@ static const uint8_t DPS310_INIT_TIMEOUT = 20; // How long to wait for DPS static const uint8_t DPS310_NUM_COEF_REGS = 18; // Number of coefficients we need to read from the device static const int32_t DPS310_SCALE_FACTOR = 1572864; // Measurement compensation scale factor -class DPS310Component : public PollingComponent, public i2c::I2CDevice { +class DPS310Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ds1307/ds1307.h b/esphome/components/ds1307/ds1307.h index 2004978cc6..238fc7b21a 100644 --- a/esphome/components/ds1307/ds1307.h +++ b/esphome/components/ds1307/ds1307.h @@ -6,7 +6,7 @@ namespace esphome::ds1307 { -class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { +class DS1307Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -55,12 +55,12 @@ class DS1307Component : public time::RealTimeClock, public i2c::I2CDevice { } ds1307_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/ds2484/ds2484.h b/esphome/components/ds2484/ds2484.h index 9e6bb08858..819b9456c1 100644 --- a/esphome/components/ds2484/ds2484.h +++ b/esphome/components/ds2484/ds2484.h @@ -8,11 +8,11 @@ namespace esphome::ds2484 { -class DS2484OneWireBus : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { +class DS2484OneWireBus final : public one_wire::OneWireBus, public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::BUS - 1.0; } + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } bool reset_device(); int reset_int() override; diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 1dc3664602..34f37ace35 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -40,9 +40,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Dsmr), - cv.Optional(CONF_DECRYPTION_KEY): lambda value: cv.bind_key( - value, name="Decryption key" - ), + cv.Optional(CONF_DECRYPTION_KEY): cv.bind_key(name="Decryption key"), cv.Optional(CONF_CRC_CHECK, default=True): cv.boolean, cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 3642309c26..321fbab824 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -65,7 +65,7 @@ using MyData = dsmr_parser::ParsedData; #endif -class Dsmr : public Component, public uart::UARTDevice { +class Dsmr final : public Component, public uart::UARTDevice { public: Dsmr(uart::UARTComponent *uart, bool crc_check, size_t max_telegram_length, uint32_t request_interval, uint32_t receive_timeout, GPIOPin *request_pin, const char *decryption_key) diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.h b/esphome/components/duty_cycle/duty_cycle_sensor.h index 58beee946a..564c47a2aa 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.h +++ b/esphome/components/duty_cycle/duty_cycle_sensor.h @@ -16,7 +16,7 @@ struct DutyCycleSensorStore { static void gpio_intr(DutyCycleSensorStore *arg); }; -class DutyCycleSensor : public sensor::Sensor, public PollingComponent { +class DutyCycleSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/duty_time/duty_time_sensor.h b/esphome/components/duty_time/duty_time_sensor.h index 9b1e10ea8c..a9e91de0b1 100644 --- a/esphome/components/duty_time/duty_time_sensor.h +++ b/esphome/components/duty_time/duty_time_sensor.h @@ -12,7 +12,7 @@ namespace esphome::duty_time_sensor { -class DutyTimeSensor : public sensor::Sensor, public PollingComponent { +class DutyTimeSensor final : public sensor::Sensor, public PollingComponent { public: void setup() override; void update() override; @@ -61,7 +61,7 @@ template class ResetAction : public BaseAction { void play(const Ts &...x) override { this->parent_->reset(); } }; -template class RunningCondition : public Condition, public Parented { +template class RunningCondition final : public Condition, public Parented { public: explicit RunningCondition(DutyTimeSensor *parent, bool state) : Parented(parent), state_(state) {} diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 6574037efb..b0a8b4f83f 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -30,7 +30,7 @@ struct UniverseConsumer { uint16_t consumers; }; -class E131Component : public esphome::Component { +class E131Component final : public esphome::Component { public: E131Component(); ~E131Component(); diff --git a/esphome/components/ee895/ee895.h b/esphome/components/ee895/ee895.h index ba8e594fea..1682e33146 100644 --- a/esphome/components/ee895/ee895.h +++ b/esphome/components/ee895/ee895.h @@ -7,7 +7,7 @@ namespace esphome::ee895 { /// This class implements support for the ee895 of temperature i2c sensors. -class EE895Component : public PollingComponent, public i2c::I2CDevice { +class EE895Component final : public PollingComponent, public i2c::I2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.h b/esphome/components/ektf2232/touchscreen/ektf2232.h index 45da74a2a5..a4b9cbd574 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.h +++ b/esphome/components/ektf2232/touchscreen/ektf2232.h @@ -10,7 +10,7 @@ namespace esphome::ektf2232 { using namespace touchscreen; -class EKTF2232Touchscreen : public Touchscreen, public i2c::I2CDevice { +class EKTF2232Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/emc2101/emc2101.h b/esphome/components/emc2101/emc2101.h index 1fe03a2630..b3ec0c5dc1 100644 --- a/esphome/components/emc2101/emc2101.h +++ b/esphome/components/emc2101/emc2101.h @@ -25,7 +25,7 @@ enum Emc2101DACConversionRate { /// This class includes support for the EMC2101 i2c fan controller. /// The device has an output (PWM or DAC) and several sensors and this /// class is for the EMC2101 configuration. -class Emc2101Component : public Component, public i2c::I2CDevice { +class Emc2101Component final : public Component, public i2c::I2CDevice { public: /** Sets the mode of the output. * diff --git a/esphome/components/emc2101/output/emc2101_output.h b/esphome/components/emc2101/output/emc2101_output.h index 95077f5524..9a7ab0659a 100644 --- a/esphome/components/emc2101/output/emc2101_output.h +++ b/esphome/components/emc2101/output/emc2101_output.h @@ -6,7 +6,7 @@ namespace esphome::emc2101 { /// This class allows to control the EMC2101 output. -class EMC2101Output : public output::FloatOutput { +class EMC2101Output final : public output::FloatOutput { public: EMC2101Output(Emc2101Component *parent) : parent_(parent) {} diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.h b/esphome/components/emc2101/sensor/emc2101_sensor.h index 2336ac2f15..943e468e7d 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.h +++ b/esphome/components/emc2101/sensor/emc2101_sensor.h @@ -7,7 +7,7 @@ namespace esphome::emc2101 { /// This class exposes the EMC2101 sensors. -class EMC2101Sensor : public PollingComponent { +class EMC2101Sensor final : public PollingComponent { public: EMC2101Sensor(Emc2101Component *parent) : parent_(parent) {} /** Used by ESPHome framework. */ diff --git a/esphome/components/emmeti/emmeti.h b/esphome/components/emmeti/emmeti.h index 9dc78ce07c..2203bfdec7 100644 --- a/esphome/components/emmeti/emmeti.h +++ b/esphome/components/emmeti/emmeti.h @@ -60,7 +60,7 @@ struct EmmetiState { uint8_t checksum = 0; }; -class EmmetiClimate : public climate_ir::ClimateIR { +class EmmetiClimate final : public climate_ir::ClimateIR { public: EmmetiClimate() : climate_ir::ClimateIR(EMMETI_TEMP_MIN, EMMETI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 67e7f5bffc..6db197a78c 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -26,7 +26,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * The EmonTx processes incoming data frames via UART, * extracts tags and values, and publishes them to registered sensors. */ -class EmonTx : public Component, public uart::UARTDevice { +class EmonTx final : public Component, public uart::UARTDevice { public: EmonTx() = default; @@ -59,7 +59,7 @@ class EmonTx : public Component, public uart::UARTDevice { }; // Action to send command to emonTx -template class EmonTxSendCommandAction : public Action, public Parented { +template class EmonTxSendCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, command) diff --git a/esphome/components/emontx/sensor/emontx_sensor.h b/esphome/components/emontx/sensor/emontx_sensor.h index 9714acdf0d..88b396bbc4 100644 --- a/esphome/components/emontx/sensor/emontx_sensor.h +++ b/esphome/components/emontx/sensor/emontx_sensor.h @@ -5,7 +5,7 @@ namespace esphome::emontx { -class EmonTxSensor : public sensor::Sensor, public Component { +class EmonTxSensor final : public sensor::Sensor, public Component { public: void dump_config() override; }; diff --git a/esphome/components/endstop/endstop_cover.h b/esphome/components/endstop/endstop_cover.h index b910139bcd..5319c74d7b 100644 --- a/esphome/components/endstop/endstop_cover.h +++ b/esphome/components/endstop/endstop_cover.h @@ -7,7 +7,7 @@ namespace esphome::endstop { -class EndstopCover : public cover::Cover, public Component { +class EndstopCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/ens160_i2c/ens160_i2c.h b/esphome/components/ens160_i2c/ens160_i2c.h index 98318a7eca..d5a0d21c62 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.h +++ b/esphome/components/ens160_i2c/ens160_i2c.h @@ -5,7 +5,7 @@ namespace esphome::ens160_i2c { -class ENS160I2CComponent : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { +class ENS160I2CComponent final : public esphome::ens160_base::ENS160Component, public i2c::I2CDevice { void dump_config() override; bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/ens160_spi/ens160_spi.h b/esphome/components/ens160_spi/ens160_spi.h index d4d3cf3ae9..821e89515f 100644 --- a/esphome/components/ens160_spi/ens160_spi.h +++ b/esphome/components/ens160_spi/ens160_spi.h @@ -5,9 +5,9 @@ namespace esphome::ens160_spi { -class ENS160SPIComponent : public esphome::ens160_base::ENS160Component, - public spi::SPIDevice { +class ENS160SPIComponent final : public esphome::ens160_base::ENS160Component, + public spi::SPIDevice { void setup() override; void dump_config() override; diff --git a/esphome/components/ens210/ens210.h b/esphome/components/ens210/ens210.h index f1520fc483..fca20133b8 100644 --- a/esphome/components/ens210/ens210.h +++ b/esphome/components/ens210/ens210.h @@ -7,7 +7,7 @@ namespace esphome::ens210 { /// This class implements support for the ENS210 relative humidity and temperature i2c sensor. -class ENS210Component : public PollingComponent, public i2c::I2CDevice { +class ENS210Component final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void setup() override; diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index a2ea28f4b6..d4ffd034a1 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -64,4 +64,21 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ } } +/** Map RGB color to discrete BWR (black/white/red) 3 color key + * + * Convenience wrapper over color_to_bwyr for panels without a yellow ink; the yellow corner is + * folded into white. + * + * @tparam NATIVE_COLOR Type of native hardware color values + * @param color RGB color to convert from + * @param hw_black Native value for black + * @param hw_white Native value for white + * @param hw_red Native value for red + * @return Converted native hardware color value + */ +template +constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_red) { + return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index b7c56a283a..0b82850f1e 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, @@ -111,6 +112,7 @@ def model_schema(config): cv.positive_time_period_milliseconds, cv.Range(max=core.TimePeriod(milliseconds=500)), ), + **model.get_config_options(), } ) @@ -129,7 +131,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 @@ -181,6 +199,7 @@ async def to_code(config): ) await display.register_display(var, config) + config = await model.to_code(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) @@ -197,6 +216,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/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp new file mode 100644 index 0000000000..fc0d674246 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -0,0 +1,148 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate2) + +#include "epaper_spi_inkplate2.h" +#include "colorconv.h" +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate2"; + +// Map RGB to the panel's black/white/red via the shared converter. +enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; + +static Inkplate2Color to_inkplate2_color(Color color) { + return color_to_bwr(color, Inkplate2Color::BLACK, Inkplate2Color::WHITE, Inkplate2Color::RED); +} + +void EPaperInkplate2::power_on() { + // Power-on (0x04) leads the init sequence, so there is nothing to do here. + ESP_LOGV(TAG, "Power on"); +} + +void EPaperInkplate2::power_off() { + ESP_LOGV(TAG, "Power off"); + this->cmd_data(0x50, {0xF7}); // VCOM and data interval + this->command(0x02); // power off +} + +void EPaperInkplate2::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); // full refresh only; partial is unused + // Send 0x11 then 0x12 back-to-back: 0x11 raises busy until the refresh finishes, so waiting for idle + // between them (as the state machine does between states) would add a ~16s stall. + this->cmd_data(0x11, {0x00}); // stop data transfer + this->command(0x12); // display refresh +} + +void EPaperInkplate2::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +void EPaperInkplate2::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); // clipping active: defer to the base per-pixel path + return; + } + + const size_t half_buffer = this->buffer_length_ / 2; + + // Plane encoding: B/W plane 1=white, 0=black; red plane 0=red, 1=no-red. + uint8_t bw_byte; + uint8_t red_byte; + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + bw_byte = 0x00; + red_byte = 0xFF; + break; + case Inkplate2Color::RED: + bw_byte = 0xFF; + red_byte = 0x00; + break; + case Inkplate2Color::WHITE: + default: + bw_byte = 0xFF; + red_byte = 0xFF; + break; + } + + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = bw_byte; + for (size_t i = half_buffer; i < this->buffer_length_; i++) + this->buffer_[i] = red_byte; + + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaperInkplate2::clear() { this->fill(COLOR_ON); } + +void HOT EPaperInkplate2::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const size_t half_buffer = this->buffer_length_ / 2; + const size_t pos = y * this->row_width_ + x / 8; + const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill() for plane encoding + + switch (to_inkplate2_color(color)) { + case Inkplate2Color::BLACK: + this->buffer_[pos] &= ~mask; + this->buffer_[pos + half_buffer] |= mask; + break; + case Inkplate2Color::RED: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] &= ~mask; + break; + case Inkplate2Color::WHITE: + default: + this->buffer_[pos] |= mask; + this->buffer_[pos + half_buffer] |= mask; + break; + } +} + +bool HOT EPaperInkplate2::send_buffer_range_(size_t end, uint32_t start_time) { + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + size_t buf_idx = 0; + while (this->current_data_index_ < end) { + bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; + if (buf_idx == sizeof bytes_to_send) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + buf_idx = 0; + if (millis() - start_time > MAX_TRANSFER_TIME) + return false; // yield; resume next loop + } + } + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + return true; +} + +bool HOT EPaperInkplate2::transfer_data() { + const uint32_t start_time = millis(); + const size_t half_buffer = this->buffer_length_ / 2; + + // Black/white plane (first half) then red plane (second half). + if (this->current_data_index_ == 0) + this->command(0x10); + if (this->current_data_index_ < half_buffer && !this->send_buffer_range_(half_buffer, start_time)) + return false; + + if (this->current_data_index_ == half_buffer) + this->command(0x13); + if (!this->send_buffer_range_(this->buffer_length_, start_time)) + return false; + + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h new file mode 100644 index 0000000000..657eca4759 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -0,0 +1,33 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 2: 104x212 black/white/red (BWR) e-paper, UC8xxx-family controller. +class EPaperInkplate2 final : public EPaperBase { + public: + EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + // Dual-plane buffer: black/white plane followed by red plane, 1 bit per pixel each. + this->buffer_length_ = this->row_width_ * this->height_ * 2; + } + + void fill(Color color) override; + void clear() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + // Streams buffer_[current_data_index_ .. end) in chunks; returns false if it yields on MAX_TRANSFER_TIME. + bool send_buffer_range_(size_t end, uint32_t start_time); +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp new file mode 100644 index 0000000000..5735333761 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -0,0 +1,367 @@ +#include "epaper_spi_t133a01.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.t133a01"; + +// Color indices used in the 4bpp buffer (sprite-side) +// These MUST match the Arduino GFX TFT_eSPI.h color definitions and +// the remap_color()/COLOR_GET mapping: +// 0x0F=BLACK, 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE +static constexpr uint8_t T133A01_BLACK = 0x0F; +static constexpr uint8_t T133A01_WHITE = 0x00; +static constexpr uint8_t T133A01_GREEN = 0x02; +static constexpr uint8_t T133A01_RED = 0x06; +static constexpr uint8_t T133A01_YELLOW = 0x0B; +static constexpr uint8_t T133A01_BLUE = 0x0D; + +// T133A01 register addresses +static constexpr uint8_t R00_PSR = 0x00; +static constexpr uint8_t R01_PWR = 0x01; +static constexpr uint8_t R02_POF = 0x02; +static constexpr uint8_t R04_PON = 0x04; +static constexpr uint8_t R05_BTST_N = 0x05; +static constexpr uint8_t R06_BTST_P = 0x06; +static constexpr uint8_t R10_DTM = 0x10; +static constexpr uint8_t R12_DRF = 0x12; +static constexpr uint8_t R50_CDI = 0x50; +static constexpr uint8_t R61_TRES = 0x61; +static constexpr uint8_t RA5_DCDC = 0xA5; +static constexpr uint8_t RE0_CCSET = 0xE0; +static constexpr uint8_t RE3_PWS = 0xE3; + +/** + * COLOR_GET remap table from T133A01_Defines.h. + * Translates 4bpp sprite color index to the hardware pixel encoding. + * Sprite: 0x0F=BLACK 0x00=WHITE 0x02=GREEN 0x06=RED 0x0B=YELLOW 0x0D=BLUE + * HW: 0x00=BLACK 0x01=WHITE 0x06=GREEN 0x03=RED 0x02=YELLOW 0x05=BLUE + */ +uint8_t EPaperT133A01::remap_color(uint8_t index) { + switch (index & 0x0F) { + case 0x0F: + return 0x00; // Black + case 0x00: + return 0x01; // White + case 0x02: + return 0x06; // Green + case 0x06: + return 0x03; // Red + case 0x0B: + return 0x02; // Yellow + case 0x0D: + return 0x05; // Blue + default: + return 0x01; // White fallback + } +} + +/** + * Map an ESPHome Color to a 4-bit sprite color index. + * Index values match the Arduino GFX TFT_eSPI color definitions: + * 0x00=WHITE, 0x02=GREEN, 0x06=RED, 0x0B=YELLOW, 0x0D=BLUE, 0x0F=BLACK + */ +uint8_t EPaperT133A01::color_to_index(Color color) { + unsigned char max_rgb = std::max({color.r, color.g, color.b}); + unsigned char min_rgb = std::min({color.r, color.g, color.b}); + + // Check for grayscale + if ((max_rgb - min_rgb) < 50) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return T133A01_WHITE; + } + return T133A01_BLACK; + } + + bool r_on = (color.r > 128); + bool g_on = (color.g > 128); + bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) + return T133A01_YELLOW; + if (r_on && !g_on && !b_on) + return T133A01_RED; + if (!r_on && g_on && !b_on) + return T133A01_GREEN; + if (!r_on && !g_on && b_on) + return T133A01_BLUE; + // Handle mixed colors: map to nearest primary + if (!r_on && g_on && b_on) + return T133A01_GREEN; // Cyan -> Green + if (r_on && !g_on) + return T133A01_RED; // Magenta -> Red + if (r_on) + return T133A01_WHITE; + return T133A01_BLACK; +} + +void EPaperT133A01::setup() { + // Base setup initialises the buffer, the standard pins and the SPI bus. + EPaperBase::setup(); + + // Both chip-selects are driven directly by this driver (the dual-CS + // protocol needs CS held HIGH while CS1 receives data, which the SPI + // bus cannot do). Start both deselected (HIGH). + this->cs_pin_->setup(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->setup(); + this->cs1_pin_->digital_write(true); +} + +bool EPaperT133A01::reset() { + for (auto *enable_pin : this->enable_pins_) { + enable_pin->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + if (this->state_ == EPaperState::RESET) { + this->reset_pin_->digital_write(false); + return false; + } + this->reset_pin_->digital_write(true); + } + return true; +} + +/** + * Initialise the T133A01 display. + * + * The init sequence uses a mix of CS and CS1 commands as per the Arduino driver. + * The base class init_sequence is NOT used for T133A01 because the dual-CS + * protocol requires per-command routing. + */ +bool EPaperT133A01::initialise(bool partial) { + // Init sequence mirrors the Arduino GFX library's EPD_INIT() macro + // (T133A01_Defines.h). Commands routed to CS only leave CS1 deselected; + // commands routed to both controllers assert CS and CS1 together. + + // 0x74 - panel config (CS only) + this->write_command_(0x74, {0x00, 0x0C, 0x0C, 0xD9, 0xDD, 0xDD, 0x15, 0x15, 0x55}, true, false); + delay(10); + + // 0xF0 - panel config (CS + CS1) + this->write_command_(0xF0, {0x49, 0x55, 0x13, 0x5D, 0x05, 0x10}, true, true); + delay(10); + + // PSR - Panel Setting Register (CS + CS1) + this->write_command_(0x00, {0xDF, 0x69}, true, true); + delay(10); + + // DCDC (CS only) + this->write_command_(RA5_DCDC, {0x44, 0x54, 0x00}, true, false); + delay(10); + + // CDI (CS + CS1) + this->write_command_(R50_CDI, {0x37}, true, true); + delay(10); + + // 0x60 (CS + CS1) + this->write_command_(0x60, {0x03, 0x03}, true, true); + delay(10); + + // 0x86 (CS + CS1) + this->write_command_(0x86, {0x10}, true, true); + delay(10); + + // PWS - Phase Width Setting (CS + CS1) + this->write_command_(RE3_PWS, {0x22}, true, true); + delay(10); + + // TRES - Resolution Setting (CS + CS1). + // With width=1200, height=1600: first word = width = 1200, second word = height/2 = 800. + this->write_command_(R61_TRES, + {(uint8_t) (this->width_ >> 8), (uint8_t) (this->width_ & 0xFF), + (uint8_t) ((this->height_ / 2) >> 8), (uint8_t) ((this->height_ / 2) & 0xFF)}, + true, true); + delay(10); + + // PWR - Power Setting (CS only) + this->write_command_(R01_PWR, {0x0F, 0x00, 0x28, 0x2C, 0x28, 0x38}, true, false); + delay(10); + + // 0xB6 (CS only) + this->write_command_(0xB6, {0x07}, true, false); + delay(10); + + // BTST_P (CS only) + this->write_command_(R06_BTST_P, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB7 (CS only) + this->write_command_(0xB7, {0x01}, true, false); + delay(10); + + // BTST_N (CS only) + this->write_command_(R05_BTST_N, {0xE0, 0x20}, true, false); + delay(10); + + // 0xB0 (CS only) + this->write_command_(0xB0, {0x01}, true, false); + delay(10); + + // 0xB1 (CS only) + this->write_command_(0xB1, {0x02}, true, false); + delay(10); + + return true; +} + +void EPaperT133A01::write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1) { + ESP_LOGV(TAG, "Command: 0x%02X, Length: %u, CS: %d, CS1: %d", command, (unsigned) length, use_cs, use_cs1); + // Chip-selects are active-low: assert the requested controllers. + this->cs_pin_->digital_write(!use_cs); + this->cs1_pin_->digital_write(!use_cs1); + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(command); + if (length > 0) { + this->dc_pin_->digital_write(true); + this->write_array(data, length); + } + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + +void EPaperT133A01::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + auto pixel_color = color_to_index(color); + this->buffer_.fill(pixel_color + (pixel_color << 4)); +} + +void EPaperT133A01::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = color_to_index(color); + uint32_t pixel_position = x + y * this->get_width_internal(); + uint32_t byte_position = pixel_position / 2; + auto original = this->buffer_[byte_position]; + if ((pixel_position & 1) != 0) { + this->buffer_[byte_position] = (original & 0xF0) | pixel_bits; + } else { + this->buffer_[byte_position] = (original & 0x0F) | (pixel_bits << 4); + } +} + +void EPaperT133A01::power_on() { + ESP_LOGV(TAG, "Power on"); + this->write_command_(R04_PON, true, true); +} + +void EPaperT133A01::power_off() { + ESP_LOGV(TAG, "Power off"); + this->write_command_(R02_POF, {0x00}, true, true); +} + +void EPaperT133A01::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + // Display Refresh + this->write_command_(R12_DRF, {0x01}, true, true); +} + +void EPaperT133A01::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->write_command_(0x07, {0xA5}, true, true); +} + +bool HOT EPaperT133A01::transfer_data() { + const uint32_t start_time = millis(); + const uint16_t bytes_per_half_row = this->width_ / 4; + const uint16_t total_rows = this->height_; + const uint16_t bytes_per_row = this->width_ / 2; + uint8_t line_data[400] = {}; + + size_t half = this->current_data_index_; + + // --- CCSET: select color set before data transfer (CS + CS1) --- + if (half == 0) { + this->write_command_(RE0_CCSET, {0x01}, true, true); + this->wait_for_idle_(true); + delay(10); + } + + // --- CS phase: left half of each row via CS --- + // T133A01 requires CS to stay LOW for the ENTIRE DTM data stream. + // Toggling CS between chunks resets the controller's data pointer, + // causing only the last chunk to be retained. Keep CS asserted + // across timeout boundaries by NOT deselecting on yield. + if (half < total_rows) { + if (half == 0) { + this->cs_pin_->digital_write(false); // select CS + this->cs1_pin_->digital_write(true); // deselect CS1 + this->dc_pin_->digital_write(false); + this->enable(); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows) { + size_t buf_offset = half * bytes_per_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS phase done"); + this->disable(); + this->cs_pin_->digital_write(true); // deselect CS + } + + // --- CS1 phase: right half of each row via CS1 --- + // Same continuous-transaction requirement as the CS phase. + // CS is held HIGH so only CS1 receives the data. + if (half >= total_rows && half < total_rows * 2) { + size_t cs1_row = half - total_rows; + + if (cs1_row == 0) { + this->cs_pin_->digital_write(true); // deselect CS + this->cs1_pin_->digital_write(false); // select CS1 + this->enable(); + this->dc_pin_->digital_write(false); + this->write_byte(R10_DTM); + this->dc_pin_->digital_write(true); + } + + while (half < total_rows * 2) { + size_t row = half - total_rows; + size_t buf_offset = row * bytes_per_row + bytes_per_half_row; + for (uint16_t col = 0; col < bytes_per_half_row; col++) { + uint8_t b = this->buffer_[buf_offset + col]; + line_data[col] = (remap_color(b >> 4) << 4) | remap_color(b & 0x0F); + } + this->write_array(line_data, bytes_per_half_row); + half++; + this->current_data_index_ = half; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + return false; + } + } + ESP_LOGD(TAG, "CS1 phase done"); + this->disable(); + this->cs1_pin_->digital_write(true); // deselect CS1 + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperT133A01::dump_config() { + EPaperBase::dump_config(); + LOG_PIN(" CS Pin: ", this->cs_pin_); + LOG_PIN(" CS1 Pin: ", this->cs1_pin_); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h new file mode 100644 index 0000000000..0d07fc03ae --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -0,0 +1,77 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * T133A01-based 6-color e-paper display driver. + * + * The T133A01 controller uses a dual-CS SPI architecture: + * - CS (primary): Controls the first half of pixel data transfer + * - CS1 (secondary): Controls panel commands (init, power, refresh) and + * the second half of pixel data transfer + * + * Color depth: 4 bits per pixel, supporting 6 colors: + * White, Green, Red, Yellow, Blue, Black + * + * Buffer layout: 2 pixels per byte (4bpp packed), total buffer size + * is width * height / 2 bytes. + */ +class EPaperT133A01 : public EPaperBase { + public: + EPaperT133A01(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR) { + this->buffer_length_ = (size_t) width * height / 2; // 2 pixels per byte at 4bpp + } + + void set_cs_pins(GPIOPin *cs, GPIOPin *cs1) { + this->cs_pin_ = cs; + this->cs1_pin_ = cs1; + } + + void fill(Color color) override; + + void setup() override; + void dump_config() override; + void draw_pixel_at(int x, int y, Color color) override; + + protected: + bool reset() override; + bool initialise(bool partial) override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + + bool transfer_data() override; + + /** + * Send a command (and optional data) selecting one or both controllers. + * Both chip-selects are active-low and managed directly by this driver. + * @param command The command byte to send + * @param data Optional pointer to data bytes to send after the command + * @param length Number of data bytes to send after the command + * @param use_cs assert CS (left controller) for this transaction + * @param use_cs1 assert CS1 (right controller) for this transaction + */ + void write_command_(uint8_t command, const uint8_t *data, size_t length, bool use_cs, bool use_cs1); + void write_command_(uint8_t command, std::initializer_list data, bool use_cs, bool use_cs1) { + this->write_command_(command, data.begin(), data.size(), use_cs, use_cs1); + } + void write_command_(uint8_t command, bool use_cs, bool use_cs1) { + this->write_command_(command, nullptr, 0, use_cs, use_cs1); + } + + /// Convert Color to 4-bit T133A01 color index + static uint8_t color_to_index(Color color); + + /// Apply COLOR_GET remap table to translate sprite indices to hardware values + static uint8_t remap_color(uint8_t index); + + GPIOPin *cs_pin_{nullptr}; + GPIOPin *cs1_pin_{nullptr}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.cpp b/esphome/components/epaper_spi/epaper_waveshare_b.cpp new file mode 100644 index 0000000000..6875811b9b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.cpp @@ -0,0 +1,13 @@ +#include "epaper_waveshare_b.h" + +namespace esphome::epaper_spi { + +bool EpaperWaveshareB::reset() { + if (EPaperBase::reset()) { + this->command(0x12); + return true; + } + return false; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.h b/esphome/components/epaper_spi/epaper_waveshare_b.h new file mode 100644 index 0000000000..3a391731d8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_b.h @@ -0,0 +1,19 @@ +#pragma once +#include "epaper_weact_3c.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare (B) series BWR e-paper displays using SSD1680-compatible controllers. + * Waveshare uses 0=red, 1=no-red, the inverse of EPaperWeAct3C + */ +class EpaperWaveshareB : public EPaperWeAct3C { + public: + using EPaperWeAct3C::EPaperWeAct3C; + + protected: + bool reset() override; + uint8_t transform_red_byte(uint8_t byte) const override { return static_cast(~byte); } +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp new file mode 100644 index 0000000000..004597b72b --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -0,0 +1,146 @@ +#include "epaper_waveshare_bwr.h" + +#include + +namespace esphome::epaper_spi { + +enum class BwrState : uint8_t { + BWR_BLACK, + BWR_WHITE, + BWR_RED, +}; + +static BwrState color_to_bwr(Color color) { + if (color.r > color.g + color.b && color.r > 127) { + return BwrState::BWR_RED; + } + if (color.r + color.g + color.b >= 382) { + return BwrState::BWR_WHITE; + } + return BwrState::BWR_BLACK; +} + +// UC8179 3-color display buffer layout: +// - 1 bit per pixel, 8 pixels per byte +// - Buffer first half: Black/White plane (1=black, 0=white) +// - Buffer second half: Red plane (1=red, 0=white) +// - Total: row_width * height * 2 bytes + +void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const uint32_t pos = (x / 8) + (y * this->row_width_); + const uint8_t bit = 0x80 >> (x & 0x07); + const uint32_t red_offset = this->buffer_length_ / 2u; + + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + this->buffer_[pos] |= bit; + } else { + this->buffer_[pos] &= ~bit; + } + + if (bwr == BwrState::BWR_RED) { + this->buffer_[red_offset + pos] |= bit; + } else { + this->buffer_[red_offset + pos] &= ~bit; + } +} + +void EPaperWaveshareBWR::fill(Color color) { + const size_t half_buffer = this->buffer_length_ / 2u; + const auto bwr = color_to_bwr(color); + + if (bwr == BwrState::BWR_BLACK) { + // Black plane: 0xFF (black), Red plane: 0x00 (no red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0xFF; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0x00; + } else if (bwr == BwrState::BWR_RED) { + // Black plane: 0x00 (no black), Red plane: 0xFF (red) + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[i] = 0x00; + for (size_t i = 0; i < half_buffer; i++) + this->buffer_[half_buffer + i] = 0xFF; + } else { + // Black plane: 0x00 (no black), Red plane: 0x00 (no red) + this->buffer_.fill(0x00); + } +} + +bool HOT EPaperWaveshareBWR::transfer_data() { + const uint32_t start_time = millis(); + const size_t buffer_length = this->buffer_length_; + const size_t half_buffer = buffer_length / 2u; + + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + + // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) + // UC8179 DTM1 (0x10): inverted to get 0=black, 1=white + if (this->current_data_index_ < half_buffer) { + if (this->current_data_index_ == 0) { + this->command(0x10); // DATA START TRANSMISSION 1 (black channel) + } + this->start_data_(); + while (this->current_data_index_ < half_buffer) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + // Phase 2: send Red plane (second half) via command 0x13 (DTM2) + // UC8179 DTM2 (0x13): 1=red, 0=white + if (this->current_data_index_ < buffer_length) { + if (this->current_data_index_ == half_buffer) { + this->command(0x13); // DATA START TRANSMISSION 2 (red channel) + } + this->start_data_(); + while (this->current_data_index_ < buffer_length) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_); + for (size_t i = 0; i < bytes_to_copy; i++) { + bytes_to_send[i] = this->buffer_[this->current_data_index_ + i]; + } + this->write_array(bytes_to_send, bytes_to_copy); + this->current_data_index_ += bytes_to_copy; + if (millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; + } + } + this->disable(); + } + + this->current_data_index_ = 0; + return true; +} + +void EPaperWaveshareBWR::power_on() { + this->cmd_data(0x01, {0x07, 0x17, 0x3F, 0x3F}); // POWER SETTING + this->command(0x04); // POWER ON +} + +void EPaperWaveshareBWR::refresh_screen(bool /*partial*/) { + this->command(0x12); // DISPLAY REFRESH +} + +void EPaperWaveshareBWR::power_off() { + this->command(0x02); // POWER OFF +} + +void EPaperWaveshareBWR::deep_sleep() { + this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h new file mode 100644 index 0000000000..a090faa14d --- /dev/null +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -0,0 +1,40 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Waveshare 3-color e-paper displays (UC8179 controller). + * Supports: 7.5" V2 BWR (EDP_7in5b_V2), 800x480 pixels. + * + * Color scheme: Black, White, Red (BWR) + * Buffer layout: 1 bit per pixel, separate planes + * - Buffer first half: Black/White plane (1=black, 0=white) + * - Buffer second half: Red plane (1=red, 0=no red) + * - Total buffer: width * height / 4 bytes (2 * width * height / 8) + * + * The init sequence (INITIALISE state) sends panel configuration only. + * Power-on (0x01 + 0x04) is sent in the POWER_ON state after data transfer; + * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). + */ +class EPaperWaveshareBWR : public EPaperBase { + public: + EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + this->buffer_length_ = this->row_width_ * height * 2; + } + + void fill(Color color) override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index d4dac7076c..ad2021ed64 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -144,7 +144,7 @@ bool HOT EPaperWeAct3C::transfer_data() { size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->buffer_[red_offset + this->current_data_index_ + i]; + bytes_to_send[i] = this->transform_red_byte(this->buffer_[red_offset + this->current_data_index_ + i]); } this->write_array(bytes_to_send, bytes_to_copy); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index 2df6f1ba09..a31c2be817 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -34,6 +34,9 @@ class EPaperWeAct3C : public EPaperBase { void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; + + // Hook for subclasses to transform red plane bytes before they go on the wire. + virtual uint8_t transform_red_byte(uint8_t byte) const { return byte; } }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 3fcf3217ec..2360b090ff 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,11 +2,15 @@ from typing import Any, Self import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj class EpaperModel: models: dict[str, Self] = {} + # Whether the driver manages chip-select itself instead of via the SPI bus. + manages_cs: bool = False + def __init__( self, name: str, @@ -35,6 +39,25 @@ class EpaperModel: def get_constructor_args(self, config) -> tuple: return () + def get_config_options(self) -> dict: + """ + Return model-specific configuration schema options. + The base implementation adds nothing; specific models override this to + declare extra options without cluttering the shared schema. + :return: A mapping suitable for cv.Schema.extend() + """ + return {} + + async def to_code(self, var: MockObj, config: dict) -> dict: + """ + Generate model-specific code for the options added by add_options(). + The base implementation does nothing; specific models override this. + The config can be updated in place to add or remove options. + :param var: The component variable + :param config: The validated configuration + """ + return config + def get_dimensions(self, config) -> tuple[int, int]: if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is diff --git a/esphome/components/epaper_spi/models/inkplate2.py b/esphome/components/epaper_spi/models/inkplate2.py new file mode 100644 index 0000000000..f1a952ce10 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate2.py @@ -0,0 +1,52 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library + +from . import EpaperModel + + +class Inkplate2Model(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate2", **kwargs): + super().__init__(name, class_name, **kwargs) + + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x04,), # power on + ( + 0x00, # panel setting + 0x0F, # LUT from OTP + 0x89, # temperature/boost/timing + ), + ( + 0x61, # resolution + width, # width: 1 byte + height >> 8, # height: 2 bytes, high byte first ... + height & 0xFF, # ... then low byte + ), + ( + 0x50, # VCOM and data interval + 0x77, + ), + ) + + +# Native orientation is portrait (104x212); use `rotation: 90` for the board's landscape orientation. +inkplate2 = Inkplate2Model( + "inkplate2", + width=104, + height=212, + data_rate="10MHz", + # A full 3-color refresh takes ~20s, so don't allow updates faster than that. + minimum_update_interval="30s", + # Default GPIO pins for the on-board Inkplate 2 wiring. + reset_pin=19, + dc_pin=33, + cs_pin=15, + busy_pin={ + "number": 32, + "inverted": True, # hardware: LOW=busy, HIGH=idle + "mode": { + "input": True, + "pullup": True, + }, + }, +) 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/epaper_spi/models/t133a01.py b/esphome/components/epaper_spi/models/t133a01.py new file mode 100644 index 0000000000..0a57b95795 --- /dev/null +++ b/esphome/components/epaper_spi/models/t133a01.py @@ -0,0 +1,71 @@ +"""T133A01-based e-paper displays. + +The T133A01 is a 6-color e-paper controller IC that drives large panels +(1200x1600 portrait). It uses a dual-CS SPI architecture where CS +controls one half of the pixel data and CS1 controls the other half, +as well as panel-level commands (power on, refresh, power off). + +Supported models: +- Seeed-reTerminal-E1004: 1200x1600 pixels, 6-color (T133A01 panel) +""" + +from esphome import pins +import esphome.codegen as cg +from esphome.const import CONF_CS_PIN +from esphome.cpp_generator import MockObj + +from . import EpaperModel + +CONF_CS1_PIN = "cs1_pin" + + +class T133A01Model(EpaperModel): + """EpaperModel subclass for T133A01-based 6-color e-paper displays.""" + + # The driver drives CS and CS1 directly for the dual-CS protocol. + manages_cs = True + + def __init__(self, name, class_name="EPaperT133A01", **defaults): + super().__init__(name, class_name, **defaults) + + def get_config_options(self) -> dict: + # CS1 is the second chip-select required by the dual-CS architecture. + # fallback=None makes it required unless the model provides a default. + return { + self.option(CONF_CS1_PIN, fallback=None): pins.gpio_output_pin_schema, + } + + async def to_code(self, var: MockObj, config: dict) -> dict: + cs = await cg.gpio_pin_expression(config[CONF_CS_PIN]) + cs1 = await cg.gpio_pin_expression(config[CONF_CS1_PIN]) + cg.add(var.set_cs_pins(cs, cs1)) + # Remove CS and CS1 from the config so that the base class doesn't try to handle them. + return {k: v for k, v in config.items() if k not in (CONF_CS_PIN, CONF_CS1_PIN)} + + +t133a01_base = T133A01Model( + "t133a01", + minimum_update_interval="30s", + data_rate="10MHz", +) + +# Seeed reTerminal E1004 - 13.3" 6-color e-paper (1200x1600, T133A01) +# Portrait orientation (1200 wide × 1600 tall), matching the Arduino +# Setup523 defines TFT_WIDTH=1200, TFT_HEIGHT=1600. +# CS and CS1 each receive half of each row's pixel data +# (300 bytes = 600 pixels per controller, for all 1600 rows). +Seeed_reTerminal_E1004 = t133a01_base.extend( + "Seeed-reTerminal-E1004", + width=1200, + height=1600, + cs_pin=10, + cs1_pin=2, + dc_pin=11, + reset_pin=38, + busy_pin={ + "number": 13, + "inverted": True, + "mode": {"input": True}, + }, + enable_pin=12, +) diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py new file mode 100644 index 0000000000..688e716456 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -0,0 +1,26 @@ +from . import EpaperModel + + +class WaveshareB(EpaperModel): + def __init__(self, name, **defaults): + super().__init__(name, "EpaperWaveshareB", **defaults) + + def get_init_sequence(self, config): + _, height = self.get_dimensions(config) + h = height - 1 + return ( + (0x01, h & 0xFF, h >> 8, 0x00), # Driver output control + (0x11, 0x03), # Data entry mode + (0x3C, 0x05), # Border waveform + (0x18, 0x80), # Internal temperature sensor + (0x21, 0x80, 0x80), # Display update control + ) + + +WaveshareB( + "waveshare-2.13in-bv4", + width=122, + height=250, + data_rate="10MHz", + minimum_update_interval="1s", +) diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py new file mode 100644 index 0000000000..e124ea7083 --- /dev/null +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -0,0 +1,56 @@ +"""Waveshare Black/White/Red e-paper displays using UC8179 controller. + +Supported models: +- waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EDP_7in5b_V2) + +These displays use the UC8179 controller. Panel configuration is sent during +the INITIALISE state. Power-on is handled in the POWER_ON state, after data +transfer, so the state machine's built-in busy wait covers the power-on delay. +""" + +from . import EpaperModel + + +class WaveshareBWR(EpaperModel): + """EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller.""" + + def __init__(self, name, **defaults): + super().__init__(name, "EPaperWaveshareBWR", **defaults) + + def get_init_sequence(self, config): + """Generate initialization sequence for UC8179 BWR displays. + + Panel configuration only — power-on is handled separately in power_on() + after data transfer, with the state machine busy-waiting before refresh. + """ + width, height = self.get_dimensions(config) + return ( + # PANEL SETTING (KWR mode) + (0x00, 0x0F), + # RESOLUTION SETTING (width x height) + ( + 0x61, + (width >> 8) & 0xFF, + width & 0xFF, + (height >> 8) & 0xFF, + height & 0xFF, + ), + # DUAL SPI MODE (disabled) + (0x15, 0x00), + # VCOM AND DATA INTERVAL SETTING + (0x50, 0x11, 0x07), + # TCON SETTING + (0x60, 0x22), + # RESOLUTION GATE SETTING + (0x65, 0x00, 0x00, 0x00, 0x00), + ) + + +# Model: Waveshare 7.5" V2 BWR (EDP_7in5b_V2) — 800x480, UC8179 controller +WaveshareBWR( + "waveshare-7.5in-bv2-bwr", + width=800, + height=480, + data_rate="10MHz", + minimum_update_interval="30s", +) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index bbd966fbe0..892b67b270 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -169,14 +169,14 @@ bool ES7210::configure_mic_gain_() { uint8_t ES7210::es7210_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) (mic_gain / 3); } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 914fbd633b..42c667b658 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -16,7 +16,7 @@ enum ES7210BitsPerSample : uint8_t { ES7210_BITS_PER_SAMPLE_32 = 32, }; -class ES7210 : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7210 ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-bsp/ (accessed 20241219) diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index b4d9fba4c5..fc3cba7ae4 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -105,14 +105,14 @@ bool ES7243E::configure_mic_gain_() { uint8_t ES7243E::es7243e_gain_reg_value_(float mic_gain) { // reg: 12 - 34.5dB, 13 - 36dB, 14 - 37.5dB - mic_gain += 0.5; - if (mic_gain <= 33.0) { + mic_gain += 0.5f; + if (mic_gain <= 33.0f) { return (uint8_t) mic_gain / 3; } - if (mic_gain < 36.0) { + if (mic_gain < 36.0f) { return 12; } - if (mic_gain < 37.0) { + if (mic_gain < 37.0f) { return 13; } return 14; diff --git a/esphome/components/es7243e/es7243e.h b/esphome/components/es7243e/es7243e.h index 6386ea529a..47dc6122c9 100644 --- a/esphome/components/es7243e/es7243e.h +++ b/esphome/components/es7243e/es7243e.h @@ -6,7 +6,7 @@ namespace esphome::es7243e { -class ES7243E : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { +class ES7243E final : public audio_adc::AudioAdc, public Component, public i2c::I2CDevice { /* Class for configuring an ES7243E ADC for microphone input. * Based on code from: * - https://github.com/espressif/esp-adf/ (accessed 20250116) diff --git a/esphome/components/es8156/es8156.h b/esphome/components/es8156/es8156.h index c3cec3dc14..d29e8d1685 100644 --- a/esphome/components/es8156/es8156.h +++ b/esphome/components/es8156/es8156.h @@ -6,7 +6,7 @@ namespace esphome::es8156 { -class ES8156 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8156 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8311/es8311.h b/esphome/components/es8311/es8311.h index 1190bcb0aa..ecc4b1014d 100644 --- a/esphome/components/es8311/es8311.h +++ b/esphome/components/es8311/es8311.h @@ -42,7 +42,7 @@ struct ES8311Coefficient { uint8_t dac_osr; // dac osr }; -class ES8311 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8311 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: ///////////////////////// // Component overrides // diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index 1f744e25b3..b01acb69c1 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -25,7 +25,7 @@ enum AdcInputMicLine : uint8_t { ADC_INPUT_MIC_DIFFERENCE, }; -class ES8388 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { #ifdef USE_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) diff --git a/esphome/components/es8388/select/adc_input_mic_select.h b/esphome/components/es8388/select/adc_input_mic_select.h index 29978f1623..2d4e8d72db 100644 --- a/esphome/components/es8388/select/adc_input_mic_select.h +++ b/esphome/components/es8388/select/adc_input_mic_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class ADCInputMicSelect : public select::Select, public Parented { +class ADCInputMicSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/es8388/select/dac_output_select.h b/esphome/components/es8388/select/dac_output_select.h index 030f12406e..f63ee8d1ba 100644 --- a/esphome/components/es8388/select/dac_output_select.h +++ b/esphome/components/es8388/select/dac_output_select.h @@ -5,7 +5,7 @@ namespace esphome::es8388 { -class DacOutputSelect : public select::Select, public Parented { +class DacOutputSelect final : public select::Select, public Parented { protected: void control(size_t index) override; }; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9c68fec69b..9b568dd629 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -11,6 +11,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg +from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -29,6 +30,7 @@ from esphome.const import ( CONF_PATH, CONF_PLATFORM_VERSION, CONF_PLATFORMIO_OPTIONS, + CONF_PROJECT, CONF_REF, CONF_SAFE_MODE, CONF_SIZE, @@ -69,6 +71,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -106,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" +CONF_NVS_ENCRYPTION = "nvs_encryption" CONF_RELEASE = "release" CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification" CONF_SIGNING_KEY = "signing_key" @@ -164,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = { VARIANT_ESP32, } +# NVS encryption (HMAC peripheral scheme) is only available on variants that +# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original +# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral +# should be added here. +NVS_ENCRYPTION_HMAC_VARIANTS = { + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32H2, + VARIANT_ESP32P4, +} + COMPILER_OPTIMIZATIONS = { "DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG", "NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE", @@ -532,15 +551,13 @@ def get_board(core_obj=None): def get_download_types(storage_json): """Binary-download entries for a built ESP32 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { @@ -597,6 +614,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, @@ -964,7 +1034,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 @@ -1046,10 +1116,124 @@ def _detect_variant(value): return value +def _ota_downgrade_protection_errors( + project_version: str | None, signed_ota_enabled: bool +) -> list[cv.Invalid]: + """Validate prerequisites for OTA downgrade protection. + + Called only when the feature is enabled. Returns a ``cv.Invalid`` for each + unmet requirement: a dotted-numeric project version (the firmware version + compared on-device) and signed OTA (so the embedded version cannot be + forged). + """ + path = [CONF_FRAMEWORK, CONF_ADVANCED, CONF_ENABLE_OTA_DOWNGRADE_PROTECTION] + errs: list[cv.Invalid] = [] + if not project_version: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires a " + f"'{CONF_PROJECT}' with a '{CONF_VERSION}' to be set in the " + f"'{CONF_ESPHOME}' section; this version is the firmware version " + "compared during OTA.", + path=path, + ) + ) + elif not re.fullmatch(r"\d+(\.\d+)*", project_version): + # The on-device comparison parses dotted-numeric versions only. + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires the " + f"'{CONF_PROJECT}' '{CONF_VERSION}' to be dotted-numeric (such " + f"as '1.2.3'), got '{project_version}'.", + path=path, + ) + ) + if not signed_ota_enabled: + errs.append( + cv.Invalid( + f"'{CONF_ENABLE_OTA_DOWNGRADE_PROTECTION}' requires " + f"'{CONF_SIGNED_OTA_VERIFICATION}' to be enabled; without signed " + "OTA the embedded version cannot be trusted.", + path=path, + ) + ) + return errs + + +_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + *SIGNING_SCHEMES, lower=True + ), + } +) + + +@schema_extractor("schema") +def _validate_signed_ota_verification(value): + if value is SCHEMA_EXTRACT: + # Expose the inner schema so the language-schema dumper can walk the + # signing_key / verification_key / signing_scheme options. + return _SIGNED_OTA_VERIFICATION_SCHEMA + if value is None: + # A bare `signed_ota_verification:` block is valid: the default V2 + # scheme needs no keys (verify externally-signed binaries). + value = {} + return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value)) + + +def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: + """Validate the signing/verification key combination for the selected scheme. + + A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1): + the public key is compiled into the app so it can verify externally-signed + images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect + when the V1 ECDSA scheme is selected and binaries are not signed during + the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig). + + The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature + block appended to each image, so verifying externally-signed binaries + needs no key in the config at all -- omitting both keys selects that + external-signing mode. + """ + has_signing_key = CONF_SIGNING_KEY in config + has_verification_key = CONF_VERIFICATION_KEY in config + scheme = config[CONF_SIGNING_SCHEME] + if has_signing_key and has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_SIGNING_KEY}' and " + f"'{CONF_VERIFICATION_KEY}', not both.", + path=[CONF_VERIFICATION_KEY], + ) + if scheme == "ecdsa_v1": + if not has_signing_key and not has_verification_key: + raise cv.Invalid( + f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' " + f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' " + f"(to verify binaries signed externally).", + path=[CONF_SIGNING_KEY], + ) + elif has_verification_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme " + f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each " + f"image's signature block, so no key file is needed to verify " + f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and " + f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during " + f"the build.", + path=[CONF_VERIFICATION_KEY], + ) + return config + + def final_validate(config): # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + from .gpio import final_validate_pins + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] @@ -1133,6 +1317,8 @@ def final_validate(config): ) ) + final_validate_pins(full_config) + if ( config[CONF_FLASH_SIZE] == "32MB" and "ota" in full_config @@ -1243,10 +1429,41 @@ def final_validate(config): ) else: _LOGGER.info( - "Signed OTA verification is configured with a public verification key. " + "Signed OTA verification is enabled without a signing key. " "Binaries will NOT be signed automatically during build. " "You must sign them externally before flashing." ) + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + variant = config[CONF_VARIANT] + if variant in NVS_ENCRYPTION_HMAC_VARIANTS: + _LOGGER.warning( + "NVS encryption will burn an HMAC key into eFuse key block %d on the " + "first boot of each device. This is PERMANENT and IRREVERSIBLE: " + "the block cannot be erased or reused afterwards. Enabling (or " + "later disabling) encryption also wipes any previously saved " + "preferences once, because the older data can no longer be read.", + nvs_enc[CONF_KEY_ID], + ) + else: + supported = ", ".join( + sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS) + ) + errs.append( + cv.Invalid( + f"NVS encryption (HMAC scheme) is not supported on " + f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). " + f"Supported variants: {supported}.", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION], + ) + ) + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project = full_config[CONF_ESPHOME].get(CONF_PROJECT) + errs.extend( + _ota_downgrade_protection_errors( + project[CONF_VERSION] if project else None, + bool(advanced.get(CONF_SIGNED_OTA_VERIFICATION)), + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -1429,16 +1646,20 @@ FRAMEWORK_SCHEMA = cv.Schema( { cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, + cv.Optional(CONF_RELEASE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional(CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_pio_platform_version, + cv.Optional( + CONF_SDKCONFIG_OPTIONS, default={}, visibility=cv.Visibility.YAML_ONLY + ): {cv.string_strict: cv.string_strict}, cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( *LOG_LEVELS_IDF, upper=True ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( *ASSERTION_LEVELS, upper=True @@ -1484,17 +1705,20 @@ FRAMEWORK_SCHEMA = cv.Schema( min=8192, max=32768 ), cv.Optional(CONF_ENABLE_OTA_ROLLBACK, default=True): cv.boolean, - cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All( - cv.Schema( - { - cv.Optional(CONF_SIGNING_KEY): cv.file_, - cv.Optional(CONF_VERIFICATION_KEY): cv.file_, - cv.Optional( - CONF_SIGNING_SCHEME, default="rsa3072" - ): cv.one_of(*SIGNING_SCHEMES, lower=True), - } - ), - cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY), + cv.Optional( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False + ): cv.boolean, + cv.Optional( + CONF_SIGNED_OTA_VERIFICATION + ): _validate_signed_ota_verification, + cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema( + { + # eFuse key block (0-5) that stores the HMAC key from + # which the NVS encryption keys are derived. The block is + # written on first boot if empty -- an irreversible + # operation -- so it must be chosen explicitly. + cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5), + } ), cv.Optional( CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False @@ -1516,7 +1740,9 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, } ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.Optional( + CONF_COMPONENTS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list( cv.All( cv.Any( cv.All(cv.string_strict, _parse_idf_component), @@ -1535,65 +1761,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 @@ -1669,7 +1842,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( *FLASH_FREQUENCIES, upper=True ), - cv.Optional(CONF_PARTITIONS): cv.Any( + cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any( cv.file_, cv.ensure_list( cv.All( @@ -1693,7 +1866,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA, - cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All( cv.positive_time_period_seconds, cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)), @@ -1847,6 +2022,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.""" @@ -2171,14 +2401,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]: @@ -2302,6 +2530,16 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE", True) cg.add_define("USE_OTA_ROLLBACK") + # Enable software OTA downgrade protection. Embed the project version into + # the image's esp_app_desc_t so the OTA backend can compare it against the + # running version (final_validate guarantees a dotted-numeric project + # version and that signed OTA is enabled). + if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]: + project_version = CORE.config[CONF_ESPHOME][CONF_PROJECT][CONF_VERSION] + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER_FROM_CONFIG", True) + add_idf_sdkconfig_option("CONFIG_APP_PROJECT_VER", project_version) + cg.add_define("USE_OTA_DOWNGRADE_PROTECTION") + # Enable signed app verification without hardware secure boot if signed_ota := advanced.get(CONF_SIGNED_OTA_VERIFICATION): add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT", True) @@ -2319,15 +2557,33 @@ async def to_code(config): signed_ota[CONF_SIGNING_KEY].resolve().as_posix(), ) else: - # Public key mode — verification only, external signing required + # External signing mode — binaries must be signed after the build add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False) - add_idf_sdkconfig_option( - "CONFIG_SECURE_BOOT_VERIFICATION_KEY", - signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), - ) + if CONF_VERIFICATION_KEY in signed_ota: + # V1 ECDSA only: the public key is compiled into the app to + # verify externally-signed images. V2 schemes carry the public + # key in each image's signature block and need no key here. + add_idf_sdkconfig_option( + "CONFIG_SECURE_BOOT_VERIFICATION_KEY", + signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(), + ) cg.add_define("USE_OTA_SIGNED_VERIFICATION") + # Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are + # derived at runtime from an HMAC key stored in the configured eFuse block + # (no flash encryption required). The HMAC key is generated and burned into + # the eFuse block on first boot if it is empty. With the scheme selected, + # nvs_sec_provider registers it at startup and the default nvs_flash_init() + # (used in esp32/preferences.cpp) transparently performs the secure init, so + # no C++ changes are needed. + if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None: + add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True) + add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True) + add_idf_sdkconfig_option( + "CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID] + ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( @@ -2397,6 +2653,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/gpio.h b/esphome/components/esp32/gpio.h index a140eeef77..aeff5af51c 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -10,7 +10,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public InternalGPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 2ff39cab69..321dd3d498 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -18,6 +18,7 @@ from esphome.const import ( PLATFORM_ESP32, ) from esphome.core import CORE +from esphome.types import ConfigType from . import boards from .const import ( @@ -50,7 +51,11 @@ from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_support from .gpio_esp32_h21 import esp32_h21_validate_gpio_pin, esp32_h21_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports -from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports +from .gpio_esp32_s3 import ( + esp32_s3_final_validate_pins, + esp32_s3_validate_gpio_pin, + esp32_s3_validate_supports, +) from .gpio_esp32_s31 import esp32_s31_validate_gpio_pin, esp32_s31_validate_supports ESP32InternalGPIOPin = esp32_ns.class_("ESP32InternalGPIOPin", cg.InternalGPIOPin) @@ -96,6 +101,7 @@ def _translate_pin(value): class ESP32ValidationFunctions: pin_validation: Callable[[int], int] usage_validation: Callable[[dict[str, Any]], dict[str, Any]] + final_validate: Callable[[ConfigType], None] | None = None _esp32_validations = { @@ -145,6 +151,7 @@ _esp32_validations = { VARIANT_ESP32S3: ESP32ValidationFunctions( pin_validation=esp32_s3_validate_gpio_pin, usage_validation=esp32_s3_validate_supports, + final_validate=esp32_s3_final_validate_pins, ), VARIANT_ESP32S31: ESP32ValidationFunctions( pin_validation=esp32_s31_validate_gpio_pin, @@ -261,3 +268,10 @@ async def esp32_pin_to_code(config): cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) return var + + +def final_validate_pins(full_config: ConfigType) -> None: + """Run the active variant's pin final-validation, if it defines one.""" + funcs = _esp32_validations.get(CORE.data[KEY_ESP32][KEY_VARIANT]) + if funcs is not None and funcs.final_validate is not None: + funcs.final_validate(full_config) diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index f528de4ccd..db8c520533 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -2,8 +2,15 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER -from esphome.pins import check_strapping_pin +from esphome.const import ( + CONF_DISABLED, + CONF_INPUT, + CONF_MODE, + CONF_NUMBER, + PLATFORM_ESP32, +) +from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin +from esphome.types import ConfigType _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", @@ -38,11 +45,9 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-S3s and is already used by the SPI/PSRAM interface(function: {_ESP32S3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32S3R8_PSRAM_PINS: - _LOGGER.warning( - "GPIO%d is used by the PSRAM interface on ESP32-S3R8 / ESP32-S3R8V and should be avoided on these models", - value, - ) + # GPIO33-37 (_ESP32S3R8_PSRAM_PINS) are only taken by the PSRAM interface in + # octal mode -- whether that applies isn't known here, so the warning is + # deferred to final_validate_pins() in gpio.py once the PSRAM mode is resolved. if value in (22, 23, 24, 25): # These pins are not exposed in GPIO mux (reason unknown) @@ -71,3 +76,29 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s3_final_validate_pins(full_config: ConfigType) -> None: + """Warn about GPIO33-37 usage, but only when octal PSRAM (which uses them) is set. + + These pins are only taken by the PSRAM interface in octal mode (ESP32-S3R8 / + S3R8V); on quad-PSRAM variants -- or when the psram block is disabled, so the + octal interface is never configured -- they are free. The per-pin validator + can't know the PSRAM mode, so the check is deferred here, where + PIN_SCHEMA_REGISTRY.pins_used already lists every used pin. + """ + # Imported locally to avoid circular import issues + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN, TYPE_OCTAL + + psram_config = full_config.get(PSRAM_DOMAIN, {}) + if psram_config.get(CONF_DISABLED) or psram_config.get(CONF_MODE) != TYPE_OCTAL: + return + for number in sorted( + number + for key, _client_id, number in PIN_SCHEMA_REGISTRY.pins_used + if key == PLATFORM_ESP32 and number in _ESP32S3R8_PSRAM_PINS + ): + _LOGGER.warning( + "GPIO%d is used by the PSRAM interface in octal mode and should be avoided", + number, + ) diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 6a19e3fee4..d49240723b 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -2,13 +2,16 @@ import logging from typing import Any import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin # Per the ESP32-S31 datasheet (page 96): # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf _ESP32S31_SPI_FLASH_PINS: set[int] = {27, 28, 29, 31, 32, 33} -_ESP32S31_STRAPPING_PINS: set[int] = {60, 61} +# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source. +_ESP32S31_STRAPPING_PINS: set[int] = {37, 60, 61} +# LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. +_ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} _LOGGER = logging.getLogger(__name__) @@ -36,3 +39,13 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value + + +def esp32_s31_validate_lp_i2c(value): + lp_sda_pin = _ESP32S31_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32S31_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-S31" + ) + return value diff --git a/esphome/components/esp32/preference_backend.h b/esphome/components/esp32/preference_backend.h index 893bc35f0c..b0771b3128 100644 --- a/esphome/components/esp32/preference_backend.h +++ b/esphome/components/esp32/preference_backend.h @@ -11,8 +11,11 @@ class ESP32PreferenceBackend final { bool save(const uint8_t *data, size_t len); bool load(uint8_t *data, size_t len); - uint32_t key; - uint32_t nvs_handle; + uint32_t key{0}; + uint32_t nvs_handle{0}; // NVS (flash) path + uint16_t rtc_offset{0}; // RTC path: word offset into the RTC storage region + uint8_t length_words{0}; // RTC path: data length in 32-bit words + bool in_flash{true}; // true: store in NVS (flash); false: store in RTC memory }; class ESP32Preferences; diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 09835385ac..dc2b40455c 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -3,7 +3,10 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" +#include #include +#include #include #include @@ -18,6 +21,48 @@ struct NVSData { static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// RTC memory backend for preferences requested with in_flash=false. Survives deep sleep and +// software/CPU resets, but not power loss; integrity is guarded by a per-record checksum so +// power-on garbage is detected on load. Keep this small: RTC memory is scarce and shared. +// +// Only compiled in when USE_ESP32_RTC_PREFERENCES_STORAGE is set (see preferences.h): the storage +// buffer reserves RTC memory, so it exists only when some config option actually selected RTC +// storage AND the variant has RTC memory (the ESP32-C2 and -C61 have none, so RTC_NOINIT_ATTR would +// have no section to land in and fail to link). Otherwise in_flash=false transparently falls back +// to NVS (see make_preference below). +// +// On variants with only RTC fast memory (C3/C6/H2/P4/C5/...) RTC_NOINIT_ATTR lands in RTC fast memory. +// This is still safe: the linker reserves .rtc_noinit ahead of any RTC-fast-as-heap pool +// (CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP), and IDF keeps the RTC fast power domain on in deep +// sleep (forced on whether or not it is used as heap), so the data is retained across both resets and +// deep sleep -- only power loss clears it. +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +static constexpr size_t RTC_PREF_SIZE_WORDS = 64; // 256 bytes +static constexpr size_t RTC_PREF_MAX_WORDS = 255; // length_words field is a uint8_t + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static RTC_NOINIT_ATTR uint32_t s_rtc_storage[RTC_PREF_SIZE_WORDS]; + +static bool save_to_rtc(uint16_t offset, uint32_t key, uint8_t length_words, const uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + rtc_pref_encode(&s_rtc_storage[offset], key, length_words, data, len); + return true; +} + +static bool load_from_rtc(uint16_t offset, uint32_t key, uint8_t length_words, uint8_t *data, size_t len) { + if (rtc_pref_bytes_to_words(len) != length_words) + return false; + const size_t buffer_size = static_cast(length_words) + 1; + if (static_cast(offset) + buffer_size > RTC_PREF_SIZE_WORDS) + return false; + return rtc_pref_decode(&s_rtc_storage[offset], key, length_words, data, len); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + // open() runs from app_main() before the logger is initialized, so any failure // must be deferred until after global_logger is set. This is emitted from the // first make_preference() call, which runs from the generated setup() after @@ -25,6 +70,10 @@ static std::vector s_pending_save; // NOLINT(cppcoreguidelines-avoid-n static esp_err_t s_open_err = ESP_OK; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return save_to_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and update that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -41,6 +90,10 @@ bool ESP32PreferenceBackend::save(const uint8_t *data, size_t len) { } bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!this->in_flash) + return load_from_rtc(this->rtc_offset, this->key, this->length_words, data, len); +#endif // try find in pending saves and load from that for (auto &obj : s_pending_save) { if (obj.key == this->key) { @@ -94,6 +147,26 @@ void ESP32Preferences::open() { } } +ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + if (!in_flash) + return this->make_rtc_preference_(length, type); +#else + if (!in_flash) { + // RTC storage is not compiled in (no config option selected it), so this request + // falls back to NVS -- the historic ESP32 behavior. Warn once so callers explicitly + // asking for RTC storage can discover the fallback. + static bool warned = false; + if (!warned) { + ESP_LOGW(TAG, "RTC preference storage not compiled in; using NVS (enable with 'preferences: rtc_storage: true')"); + warned = true; + } + } +#endif + // in_flash, or RTC storage not compiled in: fall back to NVS. + return this->make_preference(length, type); +} + ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t type) { if (s_open_err != ESP_OK) { if (this->nvs_handle == 0) { @@ -106,10 +179,34 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->nvs_handle = this->nvs_handle; pref->key = type; + pref->in_flash = true; return ESPPreferenceObject(pref); } +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE +ESPPreferenceObject ESP32Preferences::make_rtc_preference_(size_t length, uint32_t type) { + const uint32_t length_words = rtc_pref_bytes_to_words(length); + if (length_words > RTC_PREF_MAX_WORDS) { + ESP_LOGE(TAG, "RTC preference too large: %" PRIu32 " words", length_words); + return {}; + } + const uint32_t total_words = length_words + 1; // +1 for checksum + if (static_cast(this->current_rtc_offset_) + total_words > RTC_PREF_SIZE_WORDS) { + ESP_LOGE(TAG, "RTC preference storage full, cannot allocate %" PRIu32 " words", total_words); + return {}; + } + auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + pref->key = type; + pref->in_flash = false; + pref->rtc_offset = this->current_rtc_offset_; + pref->length_words = static_cast(length_words); + this->current_rtc_offset_ += static_cast(total_words); + + return ESPPreferenceObject(pref); +} +#endif // USE_ESP32_RTC_PREFERENCES_STORAGE + bool ESP32Preferences::sync() { if (s_pending_save.empty()) return true; @@ -186,6 +283,12 @@ bool ESP32Preferences::is_changed_(uint32_t nvs_handle, const NVSData &to_save, bool ESP32Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); s_pending_save.clear(); +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // Invalidate RTC-backed preferences too (checksum will no longer match). current_rtc_offset_ is + // deliberately left alone: existing backends keep pointing at their allocated slots, and reset() + // is always followed by a restart (same reason nvs_handle is zeroed below). + memset(s_rtc_storage, 0, sizeof(s_rtc_storage)); +#endif nvs_flash_deinit(); nvs_flash_erase(); diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 0e187d87a9..864d22312b 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -2,6 +2,15 @@ #ifdef USE_ESP32 #include "esphome/core/preference_backend.h" +#include + +// RTC-backed preference storage is compiled in only when a config option actually selects it +// (USE_ESP32_RTC_PREFERENCES, emitted during code generation) and the variant has RTC memory +// (SOC_RTC_MEM_SUPPORTED; the ESP32-C2 and -C61 have none). Otherwise in_flash=false falls +// back to NVS and no RTC memory is reserved. +#if defined(USE_ESP32_RTC_PREFERENCES) && SOC_RTC_MEM_SUPPORTED +#define USE_ESP32_RTC_PREFERENCES_STORAGE +#endif namespace esphome::esp32 { @@ -11,9 +20,8 @@ class ESP32Preferences final : public PreferencesMixin { public: using PreferencesMixin::make_preference; void open(); - ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { - return this->make_preference(length, type); - } + ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash); + // Two-argument form defaults to NVS (flash) storage, preserving historic ESP32 behavior. ESPPreferenceObject make_preference(size_t length, uint32_t type); bool sync(); bool reset(); @@ -22,6 +30,13 @@ class ESP32Preferences final : public PreferencesMixin { protected: bool is_changed_(uint32_t nvs_handle, const NVSData &to_save, const char *key_str); + +#ifdef USE_ESP32_RTC_PREFERENCES_STORAGE + // RTC-backed storage (in_flash=false). + ESPPreferenceObject make_rtc_preference_(size_t length, uint32_t type); + // Next free word offset in the RTC storage region (bump allocated in make_preference order). + uint16_t current_rtc_offset_{0}; +#endif }; void setup_preferences(); 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/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6bbf0d6a26..a2d19f1042 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -9,6 +9,8 @@ #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID #include #else +#include "esphome/components/watchdog/watchdog.h" +#include extern "C" { #include #include @@ -33,6 +35,19 @@ namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +// Bringing up the remote BT controller issues synchronous RPCs to the +// co-processor with 5 second response timeouts, and the default task watchdog +// is also 5 seconds. If the co-processor firmware does not answer (for example +// factory firmware without Bluetooth support), the watchdog would reboot the +// device before the RPC could return an error, causing a boot loop. Raise the +// watchdog for the duration of the bring-up so failures surface as error +// returns instead. 60 seconds covers the worst case: transport reconnect +// (up to ~20s), version preflight (1s), controller init/enable (5s each) and +// the bluedroid host bring-up over the hosted HCI transport. +static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; +#endif + // GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_ #define GAP_SCAN_COMPLETE_EVENTS \ case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \ @@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() { bool ESP32BLE::ble_setup_() { esp_err_t err; +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) { // start bt controller @@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() { esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT); #else - esp_hosted_connect_to_slave(); // NOLINT + if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT + ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled"); + return false; + } + + // Fast preflight (1 second RPC timeout): verifies the co-processor answers + // RPCs at all before the 5 second timeout BT controller RPCs below, and + // before hosted_hci_bluedroid_open(), which aborts if the transport is down. + esp_hosted_coprocessor_fwver_t fw_ver{}; + if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) { + ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted " + "update component"); + return false; + } + ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1); if (esp_hosted_bt_controller_init() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed"); + ESP_LOGE(TAG, + "BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } if (esp_hosted_bt_controller_enable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed"); + ESP_LOGE(TAG, + "BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32 + " may lack BT support. Update it with the esp32_hosted update component; BLE disabled", + fw_ver.major1, fw_ver.minor1, fw_ver.patch1); return false; } @@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() { } bool ESP32BLE::ble_dismantle_() { +#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID + // Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS + watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS); +#endif esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { // ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine @@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() { } #else if (esp_hosted_bt_controller_disable() != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed"); return false; } if (esp_hosted_bt_controller_deinit(false) != ESP_OK) { - ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed"); + ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed"); return false; } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index de8c8c2343..c85ddfc983 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -87,7 +87,7 @@ enum BLEComponentState : uint8_t { BLE_COMPONENT_STATE_ACTIVE, }; -class ESP32BLE : public Component { +class ESP32BLE final : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } @@ -236,12 +236,12 @@ class ESP32BLE : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; -template class BLEEnabledCondition : public Condition { +template class BLEEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); } }; -template class BLEEnableAction : public Action { +template class BLEEnableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) @@ -249,7 +249,7 @@ template class BLEEnableAction : public Action { } }; -template class BLEDisableAction : public Action { +template class BLEDisableAction final : public Action { public: void play(const Ts &...x) override { if (global_ble != nullptr) 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_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 8b3899a681..986778de57 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -34,7 +34,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component { +class ESP32BLEBeacon final : public Component { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} diff --git a/esphome/components/esp32_ble_client/ble_characteristic.h b/esphome/components/esp32_ble_client/ble_characteristic.h index 1428b42739..7834d99c9b 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.h +++ b/esphome/components/esp32_ble_client/ble_characteristic.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: ~BLECharacteristic(); bool parsed = false; diff --git a/esphome/components/esp32_ble_client/ble_service.h b/esphome/components/esp32_ble_client/ble_service.h index 00ecc777e7..bb1fd2b9fa 100644 --- a/esphome/components/esp32_ble_client/ble_service.h +++ b/esphome/components/esp32_ble_client/ble_service.h @@ -17,7 +17,7 @@ namespace espbt = esphome::esp32_ble_tracker; class BLEClientBase; -class BLEService { +class BLEService final { public: ~BLEService(); bool parsed = false; 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_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 933177a399..b7a3fae1a5 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -24,7 +24,7 @@ using namespace bytebuffer; class BLEService; -class BLECharacteristic { +class BLECharacteristic final { public: BLECharacteristic(ESPBTUUID uuid, uint32_t properties); ~BLECharacteristic(); diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 9ba108499e..fdd92812cd 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -23,7 +23,7 @@ namespace esphome::esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -class BLEServer : public Component, public Parented { +class BLEServer final : public Component, public Parented { public: void setup() override; void loop() override; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index b1f887e2fa..e5463847fa 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -64,7 +64,7 @@ class BLECharacteristicSetValueActionManager { void remove_listener_(BLECharacteristic *characteristic); }; -template class BLECharacteristicSetValueAction : public Action { +template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} TEMPLATABLE_VALUE(std::vector, buffer) @@ -94,7 +94,7 @@ template class BLECharacteristicSetValueAction : public Action class BLECharacteristicNotifyAction : public Action { +template class BLECharacteristicNotifyAction final : public Action { public: BLECharacteristicNotifyAction(BLECharacteristic *characteristic) : parent_(characteristic) {} void play(const Ts &...x) override { @@ -112,7 +112,7 @@ template class BLECharacteristicNotifyAction : public Action class BLEDescriptorSetValueAction : public Action { +template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} TEMPLATABLE_VALUE(std::vector, buffer) diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index 03fa8093ac..a0592d0a80 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -19,7 +19,7 @@ class BLEServer; using namespace esp32_ble; -class BLEService { +class BLEService final { public: BLEService(ESPBTUUID uuid, uint16_t num_handles, uint8_t inst_id, bool advertise); ~BLEService(); 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_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 6d26040ccb..b653325f56 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -7,7 +7,7 @@ namespace esphome::esp32_ble_tracker { #ifdef USE_ESP32_BLE_DEVICE -class ESPBTAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } @@ -28,7 +28,7 @@ class ESPBTAdvertiseTrigger : public Trigger, public ESPBTD std::vector address_vec_; }; -class BLEServiceDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEServiceDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -54,7 +54,7 @@ class BLEServiceDataAdvertiseTrigger : public Trigger, publi ESPBTUUID uuid_; }; -class BLEManufacturerDataAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit BLEManufacturerDataAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } void set_address(uint64_t address) { this->address_ = address; } @@ -82,7 +82,7 @@ class BLEManufacturerDataAdvertiseTrigger : public Trigger, #endif // USE_ESP32_BLE_DEVICE -class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { public: explicit BLEEndOfScanTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } @@ -92,7 +92,7 @@ class BLEEndOfScanTrigger : public Trigger<>, public ESPBTDeviceListener { void on_scan_end() override { this->trigger(); } }; -template class ESP32BLEStartScanAction : public Action { +template class ESP32BLEStartScanAction final : public Action { public: ESP32BLEStartScanAction(ESP32BLETracker *parent) : parent_(parent) {} TEMPLATABLE_VALUE(bool, continuous) @@ -111,7 +111,7 @@ template class ESP32BLEStartScanAction : public Action { ESP32BLETracker *parent_; }; -template class ESP32BLEStopScanAction : public Action, public Parented { +template class ESP32BLEStopScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_scan(); } }; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 78ff60f374..3415196a11 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -294,11 +294,11 @@ class ESPBTClient : public ESPBTDeviceListener { uint8_t *tracker_state_version_{nullptr}; }; -class ESP32BLETracker : public Component, +class ESP32BLETracker final : public Component, #ifdef USE_OTA_STATE_LISTENER - public ota::OTAGlobalStateListener, + public ota::OTAGlobalStateListener, #endif - public Parented { + public Parented { public: void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; } void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 7d020b5caf..83dab5f77a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -119,7 +119,7 @@ class ESP32CameraImageReader : public camera::CameraImageReader { }; /* ---------------- ESP32Camera class ---------------- */ -class ESP32Camera : public camera::Camera { +class ESP32Camera final : public camera::Camera { public: ESP32Camera(); @@ -235,7 +235,7 @@ class ESP32Camera : public camera::Camera { RAMAllocator fb_allocator_{RAMAllocator::ALLOC_INTERNAL}; }; -class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { +class ESP32CameraImageTrigger final : public Trigger, public camera::CameraListener { public: explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_camera_image(const std::shared_ptr &image) override { @@ -246,13 +246,13 @@ class ESP32CameraImageTrigger : public Trigger, public camera:: } }; -class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStartTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { +class ESP32CameraStreamStopTrigger final : public Trigger<>, public camera::CameraListener { public: explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } void on_stream_stop() override { this->trigger(); } diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index 568dc68c46..76f8317248 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -17,7 +17,7 @@ namespace esphome::esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component, public camera::CameraListener { +class CameraWebServer final : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index 2e10d254e6..f224e10be3 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -14,7 +14,7 @@ enum CanMode : uint8_t { CAN_MODE_LISTEN_ONLY = 1, }; -class ESP32Can : public canbus::Canbus { +class ESP32Can final : public canbus::Canbus { public: void set_rx(int rx) { rx_ = rx; } void set_tx(int tx) { tx_ = tx; } diff --git a/esphome/components/esp32_dac/esp32_dac.h b/esphome/components/esp32_dac/esp32_dac.h index 108b96cd39..ee6b506211 100644 --- a/esphome/components/esp32_dac/esp32_dac.h +++ b/esphome/components/esp32_dac/esp32_dac.h @@ -11,7 +11,7 @@ namespace esphome::esp32_dac { -class ESP32DAC : public output::FloatOutput, public Component { +class ESP32DAC final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 7f420f27d8..16e9d49782 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( from esphome.cpp_generator import add_define CODEOWNERS = ["@swoboda1337"] +# esp32_ble raises the task watchdog around the remote BT controller bring-up +AUTO_LOAD = ["watchdog"] CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 005e6a6f21..4f9d04738d 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -13,7 +13,7 @@ namespace esphome::esp32_hosted { -class Esp32HostedUpdate : public update::UpdateEntity, public PollingComponent { +class Esp32HostedUpdate final : public update::UpdateEntity, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/esp32_improv/automation.h index 19e1b6e7e3..b3b61f4778 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/esp32_improv/automation.h @@ -9,7 +9,7 @@ namespace esphome::esp32_improv { -class ESP32ImprovProvisionedTrigger : public Trigger<> { +class ESP32ImprovProvisionedTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -23,7 +23,7 @@ class ESP32ImprovProvisionedTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovProvisioningTrigger : public Trigger<> { +class ESP32ImprovProvisioningTrigger final : public Trigger<> { public: explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -37,7 +37,7 @@ class ESP32ImprovProvisioningTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStartTrigger : public Trigger<> { +class ESP32ImprovStartTrigger final : public Trigger<> { public: explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -52,7 +52,7 @@ class ESP32ImprovStartTrigger : public Trigger<> { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStateTrigger : public Trigger { +class ESP32ImprovStateTrigger final : public Trigger { public: explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { @@ -66,7 +66,7 @@ class ESP32ImprovStateTrigger : public Trigger { ESP32ImprovComponent *parent_; }; -class ESP32ImprovStoppedTrigger : public Trigger<> { +class ESP32ImprovStoppedTrigger final : public Trigger<> { public: explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index e6fcc018d9..6e3a4ef526 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -7,6 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + #ifdef USE_ESP32 namespace esphome::esp32_improv { @@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() { #endif global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); }); +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + ESP_LOGD(TAG, "Provisioning window closed; stopping Improv"); + this->stop(); + }); + } +#endif + // Start with loop disabled - will be enabled by start() when needed this->disable_loop(); } @@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; +#ifdef USE_PROVISIONING + // Don't (re)start advertising once the provisioning window has closed - e.g. when + // wifi tries to restart Improv after the window expired at runtime. + if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) { + ESP_LOGD(TAG, "Provisioning window closed; not starting Improv"); + return; + } +#endif + ESP_LOGD(TAG, "Setting Improv to start"); this->should_start_ = true; this->enable_loop(); @@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() { this->incoming_data_.clear(); return; } +#ifdef USE_PROVISIONING + if (provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->closed()) { + ESP_LOGW(TAG, "Provisioning window closed; refusing settings"); + this->set_error_(improv::ERROR_NOT_AUTHORIZED); + this->incoming_data_.clear(); + return; + } +#endif 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. diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 400006cfb3..d948dba3b3 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -32,7 +32,7 @@ namespace esphome::esp32_improv { using namespace esp32_ble_server; -class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { +class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { public: ESP32ImprovComponent(); void dump_config() override; diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 8fb6b63afe..d7ba2aafbf 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -30,7 +30,7 @@ struct LedParams { rmt_symbol_word_t reset; }; -class ESP32RMTLEDStripLightOutput : public light::AddressableLight { +class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; diff --git a/esphome/components/esp32_touch/esp32_touch.h b/esphome/components/esp32_touch/esp32_touch.h index d51b2d4922..55ac5c5b63 100644 --- a/esphome/components/esp32_touch/esp32_touch.h +++ b/esphome/components/esp32_touch/esp32_touch.h @@ -172,7 +172,7 @@ class ESP32TouchComponent final : public Component { }; /// Simple helper class to expose a touch pad value as a binary sensor. -class ESP32TouchBinarySensor : public binary_sensor::BinarySensor { +class ESP32TouchBinarySensor final : public binary_sensor::BinarySensor { public: ESP32TouchBinarySensor(int channel_id, uint32_t threshold, uint32_t wakeup_threshold) : channel_id_(channel_id), threshold_(threshold), wakeup_threshold_(wakeup_threshold) {} diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index db94f0ec6d..0e0e2f77d7 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -97,15 +97,13 @@ def set_core_data(config): def get_download_types(storage_json): """Binary-download entries for a built ESP8266 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { @@ -133,7 +131,6 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases @@ -205,8 +202,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, @@ -313,6 +314,14 @@ async def to_code(config): # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` cg.add_build_flag("-DNEW_OOM_ABORT") + # Force-include inline std::__throw_* overrides so GCC dead-strips the unused + # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. + # See throw_stubs.h for details. Must be prepended before , so this + # uses build_src_flags with -include. + cg.add_platformio_option( + "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" + ) + # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing # we pretend it has much larger memory to test that components compile together @@ -327,6 +336,12 @@ async def to_code(config): for symbol in ("vprintf", "printf", "fprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap the lwIP2 glue's do-nothing dhcp_cleanup()/dhcp_release() stubs so the + # linker can drop their "STUB: ..." message strings from DRAM. + # See lwip_glue_stubs.cpp for implementation. + for symbol in ("dhcp_cleanup", "dhcp_release"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + # Wrap Arduino's millis() so all callers (including Arduino libraries and ISR # handlers) use our fast accumulator instead of the expensive 4x 64-bit multiply # implementation in the Arduino ESP8266 core. diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index ff149abfbe..57ef06106a 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -7,7 +7,7 @@ namespace esphome::esp8266 { -class ESP8266GPIOPin : public InternalGPIOPin { +class ESP8266GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/esp8266/helpers.cpp b/esphome/components/esp8266/helpers.cpp index aadfc31197..2e0a00325c 100644 --- a/esphome/components/esp8266/helpers.cpp +++ b/esphome/components/esp8266/helpers.cpp @@ -12,7 +12,7 @@ namespace esphome { uint32_t random_uint32() { return os_random(); } bool random_bytes(uint8_t *data, size_t len) { return os_get_random(data, len) == 0; } -// ESP8266 Mutex is defined inline as a no-op in helpers.h when USE_ESP8266 (or USE_RP2040) is set, +// ESP8266 Mutex is defined inline as a no-op in helpers.h when USE_ESP8266 (or USE_RP2) is set, // independent of the ESPHOME_THREAD_SINGLE thread model define. IRAM_ATTR InterruptLock::InterruptLock() { state_ = xt_rsil(15); } diff --git a/esphome/components/esp8266/lwip_glue_stubs.cpp b/esphome/components/esp8266/lwip_glue_stubs.cpp new file mode 100644 index 0000000000..a86c8d75a2 --- /dev/null +++ b/esphome/components/esp8266/lwip_glue_stubs.cpp @@ -0,0 +1,35 @@ +/* + * Linker wrap stubs for the lwIP2 glue's dead DHCP entry points. + * + * The ESP8266 SDK blobs call dhcp_cleanup() and dhcp_release() when the + * station leaves an access point (cnx_sta_leave, wifi_station_dhcpc_stop). + * In the prebuilt lwIP2 glue (liblwip2-*.a, glue-esp/lwip-esp.c) these are + * stubs whose only effect is printing "STUB: dhcp_cleanup" and + * "STUB: dhcp_release"; the real DHCP teardown happens through lwIP2's + * renamed dhcp_cleanup_LWIP2()/dhcp_release_LWIP2() functions. + * + * On ESP8266 .rodata lives in DRAM, so those message strings waste scarce + * RAM. Wrapping the stubs with silent equivalents lets the linker garbage + * collect the glue stub bodies together with their strings. + * + * Saves 38 bytes of RAM and removes the "STUB:" log noise on Wi-Fi + * disconnect. Behavior is otherwise unchanged. + */ + +#if defined(USE_ESP8266) + +namespace esphome::esp8266 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +// The callers are closed-source SDK blobs; the netif argument is unused. +void __wrap_dhcp_cleanup(void * /*netif*/) {} + +// The glue stub returns ERR_ABRT (-8; lwIP 1.4 err_t is a signed char). +signed char __wrap_dhcp_release(void * /*netif*/) { return -8; } + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index 696f83bce1..d954ae4a0f 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -8,6 +8,7 @@ extern "C" { #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences_rtc.h" #include @@ -80,16 +81,6 @@ static uint32_t get_esp8266_flash_sector() { } static uint32_t get_esp8266_flash_address() { return get_esp8266_flash_sector() * SPI_FLASH_SEC_SIZE; } -static inline size_t bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } - -template uint32_t calculate_crc(It first, It last, uint32_t type) { - uint32_t crc = type; - while (first != last) { - crc ^= (*first++ * 2654435769UL) >> 1; - } - return crc; -} - static bool save_to_flash(size_t offset, const uint32_t *data, size_t len) { for (uint32_t i = 0; i < len; i++) { uint32_t j = offset + i; @@ -137,21 +128,19 @@ static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; bool ESP8266PreferenceBackend::save(const uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) return false; uint32_t buffer[PREF_MAX_BUFFER_WORDS]; - memset(buffer, 0, buffer_size * sizeof(uint32_t)); - memcpy(buffer, data, len); - buffer[this->length_words] = calculate_crc(buffer, buffer + this->length_words, this->type); + rtc_pref_encode(buffer, this->type, this->length_words, data, len); return this->in_flash ? save_to_flash(this->offset, buffer, buffer_size) : save_to_rtc(this->offset, buffer, buffer_size); } bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { - if (bytes_to_words(len) != this->length_words) + if (rtc_pref_bytes_to_words(len) != this->length_words) return false; const size_t buffer_size = static_cast(this->length_words) + 1; if (buffer_size > PREF_MAX_BUFFER_WORDS) @@ -161,10 +150,7 @@ bool ESP8266PreferenceBackend::load(uint8_t *data, size_t len) { : load_from_rtc(this->offset, buffer, buffer_size); if (!ret) return false; - if (buffer[this->length_words] != calculate_crc(buffer, buffer + this->length_words, this->type)) - return false; - memcpy(data, buffer, len); - return true; + return rtc_pref_decode(buffer, this->type, this->length_words, data, len); } void ESP8266Preferences::setup() { @@ -177,13 +163,13 @@ void ESP8266Preferences::setup() { } ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t type, bool in_flash) { - const uint32_t length_words = bytes_to_words(length); + const uint32_t length_words = rtc_pref_bytes_to_words(length); if (length_words > MAX_PREFERENCE_WORDS) { ESP_LOGE(TAG, "Preference too large: %u words", static_cast(length_words)); return {}; } - const uint32_t total_words = length_words + 1; // +1 for CRC + const uint32_t total_words = length_words + 1; // +1 for checksum uint16_t offset; if (in_flash) { diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h new file mode 100644 index 0000000000..a650935a5e --- /dev/null +++ b/esphome/components/esp8266/throw_stubs.h @@ -0,0 +1,41 @@ +#pragma once +/* + * Inline overrides for std::__throw_* helpers (ESP8266). + * + * ESP8266 Arduino compiles with -fno-exceptions and ships a libstdc++ whose + * std::__throw_* functions already just call abort() -- they never read their + * const char* message argument. But the compiler still emits the message load + * at every throw site (inside header-instantiated std::string / std::vector + * code), so --gc-sections keeps those libstdc++ error strings alive. On + * ESP8266 .rodata lives in DRAM, so each one wastes scarce RAM (e.g. + * "basic_string::_M_construct null not valid", "basic_string::_M_create", + * "cannot create std::vector larger than max_size()", "array::at: ..."). + * + * Providing inline definitions here lets GCC see the message argument is + * unused, dead-strip the load, and drop the string entirely -- no LTO needed. + * Behavior is identical to today: a bare abort() (the message was never + * printed). This header MUST be force-included before , so it is + * wired up via build_src_flags "-include ..." in this component's __init__.py. + * + * Note: this defines functions in namespace std (technically UB). It is safe + * here because the definitions match the existing abort() behavior exactly. + */ + +#ifdef __cplusplus + +// Empty namespace so the CI namespace check is satisfied; the overrides below +// must live in namespace std, so they cannot go in the component namespace. +namespace esphome::esp8266 {} // namespace esphome::esp8266 + +// NOLINTBEGIN(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) +namespace std { + +__attribute__((__noreturn__)) inline void __throw_logic_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_length_error(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range(const char *) { __builtin_abort(); } +__attribute__((__noreturn__)) inline void __throw_out_of_range_fmt(const char *, ...) { __builtin_abort(); } + +} // namespace std +// NOLINTEND(bugprone-reserved-identifier,bugprone-std-namespace-modification,cert-dcl37-c,cert-dcl51-cpp,cert-dcl58-cpp,readability-identifier-naming) + +#endif // __cplusplus diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 51c4ea1602..be58a098b6 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -9,7 +9,7 @@ namespace esphome::esp8266_pwm { -class ESP8266PWM : public output::FloatOutput, public Component { +class ESP8266PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -34,7 +34,7 @@ class ESP8266PWM : public output::FloatOutput, public Component { float last_output_{0.0}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/esp_ldo/esp_ldo.h b/esphome/components/esp_ldo/esp_ldo.h index bb1579e83d..0451c338dd 100644 --- a/esphome/components/esp_ldo/esp_ldo.h +++ b/esphome/components/esp_ldo/esp_ldo.h @@ -6,7 +6,7 @@ namespace esphome::esp_ldo { -class EspLdo : public Component { +class EspLdo final : public Component { public: EspLdo(int channel) : channel_(channel) {} @@ -27,7 +27,7 @@ class EspLdo : public Component { esp_ldo_channel_handle_t handle_{}; }; -template class AdjustAction : public Action { +template class AdjustAction final : public Action { public: explicit AdjustAction(EspLdo *ldo) : ldo_(ldo) {} diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 66a33e1935..d5f4efbc02 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -126,7 +126,7 @@ CONFIG_SCHEMA = cv.All( CONF_PORT, esp8266=8266, esp32=3232, - rp2040=2040, + rp2=2040, bk72xx=8892, ln882x=8820, rtl87xx=8892, diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index fb0cc2e56d..cab725f704 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -8,7 +8,7 @@ #include "esphome/components/ota/ota_backend.h" #include "esphome/components/ota/ota_backend_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_libretiny.h" -#include "esphome/components/ota/ota_backend_arduino_rp2040.h" +#include "esphome/components/ota/ota_backend_arduino_rp2.h" #include "esphome/components/ota/ota_backend_esp_idf.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -94,11 +94,12 @@ void ESPHomeOTAComponent::setup() { } void ESPHomeOTAComponent::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" " Version: %d", - network::get_use_address(), this->port_, USE_OTA_VERSION); + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 13f278d3bc..c6c90ed67a 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -41,7 +41,7 @@ DeletePeerAction = espnow_ns.class_("DeletePeerAction", automation.Action) ESPNowHandlerTrigger = automation.Trigger.template( ESPNowRecvInfoConstRef, cg.uint8.operator("const").operator("ptr"), - cg.uint8, + cg.uint16, ) OnUnknownPeerTrigger = espnow_ns.class_( @@ -56,6 +56,20 @@ OnBroadcastTrigger = espnow_ns.class_( CONF_AUTO_ADD_PEER = "auto_add_peer" +CONF_MAX_PAYLOAD_SIZE = "max_payload_size" + +# Payload limits of ESP-NOW v1 and v2 frames. The radio negotiates the +# protocol version per peer on its own; the option only sizes this device's +# packet buffers, whose static RAM cost is proportional to it (~8 KB at 250 +# bytes, ~44 KB at 1470). +ESPNOW_PAYLOAD_V1 = 250 +ESPNOW_PAYLOAD_V2 = 1470 + +# Config-time cap for action payloads. The per-device limit is the +# ``max_payload_size`` option, which the action schema cannot see; send() +# enforces it at runtime. +MAX_ESPNOW_PACKET_SIZE = ESPNOW_PAYLOAD_V2 + CONF_PEERS = "peers" CONF_ON_SENT = "on_sent" CONF_ON_UNKNOWN_PEER = "on_unknown_peer" @@ -63,7 +77,15 @@ CONF_ON_BROADCAST = "on_broadcast" CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -MAX_ESPNOW_PACKET_SIZE = 250 # Maximum size of the payload in bytes + +def _validate_max_payload_size(value: int) -> int: + if value > ESPNOW_PAYLOAD_V1: + return cv.require_framework_version( + esp_idf=cv.Version(5, 4, 0), + esp32_arduino=cv.Version(3, 2, 0), + extra_message="ESP-NOW v2 frames need an ESP-NOW v2 capable framework", + )(value) + return value def validate_channel(value): @@ -78,6 +100,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(): cv.declare_id(ESPNowComponent), cv.OnlyWithout(CONF_CHANNEL, CONF_WIFI): validate_channel, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_MAX_PAYLOAD_SIZE, default=ESPNOW_PAYLOAD_V1): cv.All( + cv.int_range(min=1, max=ESPNOW_PAYLOAD_V2), _validate_max_payload_size + ), cv.Optional(CONF_AUTO_ADD_PEER, default=False): cv.boolean, cv.Optional(CONF_PEERS): cv.ensure_list(cv.mac_address), cv.Optional(CONF_ON_UNKNOWN_PEER): automation.validate_automation( @@ -113,7 +138,7 @@ async def _trigger_to_code(config): [ (ESPNowRecvInfoConstRef, "info"), (cg.uint8.operator("const").operator("ptr"), "data"), - (cg.uint8, "size"), + (cg.uint16, "size"), ], config, ) @@ -125,6 +150,7 @@ async def to_code(config): await cg.register_component(var, config) cg.add_define("USE_ESPNOW") + cg.add_define("USE_ESPNOW_MAX_PAYLOAD_SIZE", config[CONF_MAX_PAYLOAD_SIZE]) if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 9c3c55e4ef..e4d01bb1a8 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -9,7 +9,7 @@ namespace esphome::espnow { -template class SendAction : public Action, public Parented { +template class SendAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); TEMPLATABLE_VALUE(std::vector, data); @@ -86,7 +86,7 @@ template class SendAction : public Action, public Parente } flags_{0}; }; -template class AddPeerAction : public Action, public Parented { +template class AddPeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -96,7 +96,7 @@ template class AddPeerAction : public Action, public Pare } }; -template class DeletePeerAction : public Action, public Parented { +template class DeletePeerAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); protected: @@ -106,7 +106,7 @@ template class DeletePeerAction : public Action, public P } }; -template class SetChannelAction : public Action, public Parented { +template class SetChannelAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, channel) protected: @@ -119,8 +119,8 @@ template class SetChannelAction : public Action, public P } }; -class OnReceiveTrigger : public Trigger, - public ESPNowReceivedPacketHandler { +class OnReceiveTrigger final : public Trigger, + public ESPNowReceivedPacketHandler { public: explicit OnReceiveTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); @@ -128,7 +128,7 @@ class OnReceiveTrigger : public Triggerhas_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; @@ -141,23 +141,23 @@ class OnReceiveTrigger : public Trigger, - public ESPNowUnknownPeerHandler { +class OnUnknownPeerTrigger final : public Trigger, + public ESPNowUnknownPeerHandler { public: - bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override { this->trigger(info, data, size); return false; // Return false to continue processing other internal handlers } }; -class OnBroadcastTrigger : public Trigger, - public ESPNowBroadcastHandler { +class OnBroadcastTrigger final : public Trigger, + public ESPNowBroadcastHandler { public: explicit OnBroadcastTrigger(std::array address) : has_address_(true) { memcpy(this->address_, address.data(), ESP_NOW_ETH_ALEN); } explicit OnBroadcastTrigger() {} - bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override { + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override { bool match = !this->has_address_ || (memcmp(this->address_, info.src_addr, ESP_NOW_ETH_ALEN) == 0); if (!match) return false; diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 91f2c067ca..f28d7f3354 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,6 +4,7 @@ #include "espnow_err.h" +#include #include #include "esphome/core/application.h" @@ -96,9 +97,9 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger - // frame would overflow packet_.receive.data. - if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) { + // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // larger frame would overflow packet_.receive.data. + if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { global_esp_now->receive_packet_queue_.increment_dropped_count(); return; } @@ -285,11 +286,14 @@ void ESPNowComponent::loop() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + // Cap the hex dump at a v1 frame: a full v2 frame would need a + // ~4.4 KB stack buffer. char hex_buf[format_hex_pretty_size(ESP_NOW_MAX_DATA_LEN)]; format_mac_addr_upper(info.src_addr, src_buf); format_mac_addr_upper(info.des_addr, dst_buf); ESP_LOGV(TAG, "<<< [%s -> %s] %s", src_buf, dst_buf, - format_hex_pretty_to(hex_buf, packet->packet_.receive.data, packet->packet_.receive.size)); + format_hex_pretty_to(hex_buf, packet->packet_.receive.data, + std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { @@ -362,7 +366,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; - } else if (size > ESP_NOW_MAX_DATA_LEN) { + } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; } else if (!esp_now_is_peer_exist(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ff9581ec2f..d95255c5df 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -62,7 +62,7 @@ class ESPNowUnknownPeerHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_unknown_peer(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow packets @@ -74,7 +74,7 @@ class ESPNowReceivedPacketHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; /// Handler interface for receiving ESPNow broadcast packets /// Components should inherit from this class to handle incoming ESPNow data @@ -85,10 +85,10 @@ class ESPNowBroadcastHandler { /// @param data Pointer to the received data payload /// @param size Size of the received data in bytes /// @return true if the packet was handled, false otherwise - virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) = 0; + virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; -class ESPNowComponent : public Component { +class ESPNowComponent final : public Component { public: ESPNowComponent(); void setup() override; diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index b6192a0d41..fb125864fb 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -19,6 +19,23 @@ namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static const uint8_t ESPNOW_MULTICAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE}; +// Maximum payload this component sends and receives, from the +// ``max_payload_size`` option. The radio stack speaks ESP-NOW v2 regardless +// (negotiated per peer); payloads beyond the v1 limit (250 bytes) are opt-in +// because the packet pools are statically sized from this, so their RAM cost +// is proportional (~8 KB at 250 bytes, ~44 KB at the v2 limit of 1470). +#ifndef USE_ESPNOW_MAX_PAYLOAD_SIZE +#define USE_ESPNOW_MAX_PAYLOAD_SIZE ESP_NOW_MAX_DATA_LEN +#endif +static constexpr uint16_t ESPNOW_MAX_DATA_LEN = USE_ESPNOW_MAX_PAYLOAD_SIZE; +#ifdef ESP_NOW_MAX_DATA_LEN_V2 +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN_V2, + "espnow max_payload_size cannot exceed the ESP-NOW v2 frame limit"); +#else +static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, + "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); +#endif + struct WifiPacketRxControl { int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or @@ -78,10 +95,10 @@ class ESPNowPacket { union { // NOLINTNEXTLINE(readability-identifier-naming) struct received_data { - ESPNowRecvInfo info; // Information about the received packet - uint8_t data[ESP_NOW_MAX_DATA_LEN]; // Data received in the packet - uint8_t size; // Size of the received data - WifiPacketRxControl rx_ctrl; // Status of the received packet + ESPNowRecvInfo info; // Information about the received packet + uint8_t data[ESPNOW_MAX_DATA_LEN]; // Data received in the packet + uint16_t size; // Size of the received data + WifiPacketRxControl rx_ctrl; // Status of the received packet } receive; // NOLINTNEXTLINE(readability-identifier-naming) @@ -144,15 +161,15 @@ class ESPNowSendPacket { this->callback_ = nullptr; // Reset callback } - uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to - uint8_t data_[ESP_NOW_MAX_DATA_LEN]{0}; // Data to send - uint8_t size_{0}; // Size of the data to send, must be <= ESP_NOW_MAX_DATA_LEN - send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete + uint8_t address_[ESP_NOW_ETH_ALEN]{0}; // MAC address of the peer to send the packet to + uint8_t data_[ESPNOW_MAX_DATA_LEN]{0}; // Data to send + uint16_t size_{0}; // Size of the data to send, must be <= ESPNOW_MAX_DATA_LEN + send_callback_t callback_{nullptr}; // Callback to call when the send operation is complete private: void init_data_(const uint8_t *peer_address, const uint8_t *payload, size_t size) { memcpy(this->address_, peer_address, ESP_NOW_ETH_ALEN); - if (size > ESP_NOW_MAX_DATA_LEN) { + if (size > ESPNOW_MAX_DATA_LEN) { this->size_ = 0; return; } diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 1e37073321..b7686f23d6 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -42,8 +42,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { return; } - if (buf.size() > ESP_NOW_MAX_DATA_LEN) { - ESP_LOGE(TAG, "Packet too large: %zu bytes (max %d)", buf.size(), ESP_NOW_MAX_DATA_LEN); + if (buf.size() > ESPNOW_MAX_DATA_LEN) { + ESP_LOGE(TAG, "Packet too large: %zu bytes (max %u)", buf.size(), (unsigned) ESPNOW_MAX_DATA_LEN); return; } @@ -55,8 +55,8 @@ void ESPNowTransport::send_packet(const std::vector &buf) const { }); } -bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], +bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { @@ -70,9 +70,9 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; // Allow other handlers to run } -bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) { - ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", size, info.src_addr[0], - info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); +bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) { + ESP_LOGV(TAG, "Received broadcast packet of size %u from %02X:%02X:%02X:%02X:%02X:%02X", (unsigned) size, + info.src_addr[0], info.src_addr[1], info.src_addr[2], info.src_addr[3], info.src_addr[4], info.src_addr[5]); if (data == nullptr || size == 0) { ESP_LOGW(TAG, "Received empty or null broadcast packet"); diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 5916a7fa5f..51069b6415 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -11,10 +11,10 @@ namespace esphome::espnow { -class ESPNowTransport : public packet_transport::PacketTransport, - public Parented, - public ESPNowReceivedPacketHandler, - public ESPNowBroadcastHandler { +class ESPNowTransport final : public packet_transport::PacketTransport, + public Parented, + public ESPNowReceivedPacketHandler, + public ESPNowBroadcastHandler { public: void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } @@ -24,12 +24,12 @@ class ESPNowTransport : public packet_transport::PacketTransport, } // ESPNow handler interface - bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; - bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint8_t size) override; + bool on_receive(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; + bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) override; protected: void send_packet(const std::vector &buf) const override; - size_t get_max_packet_size() override { return ESP_NOW_MAX_DATA_LEN; } + size_t get_max_packet_size() override { return ESPNOW_MAX_DATA_LEN; } bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 784f5dee8c..03fba7164d 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,7 +4,7 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.network import ip_address_literal +from esphome.components.network import add_use_address, ip_address_literal from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -126,6 +126,8 @@ ETHERNET_TYPES = { "ENC28J60": EthernetType.ETHERNET_TYPE_ENC28J60, "W6100": EthernetType.ETHERNET_TYPE_W6100, "W6300": EthernetType.ETHERNET_TYPE_W6300, + "GENERIC": EthernetType.ETHERNET_TYPE_GENERIC, + "YT8531": EthernetType.ETHERNET_TYPE_YT8531, } # PHY types that need compile-time defines for conditional compilation @@ -145,6 +147,8 @@ _PHY_TYPE_TO_DEFINE = { "ENC28J60": "USE_ETHERNET_ENC28J60", "W6100": "USE_ETHERNET_W6100", "W6300": "USE_ETHERNET_W6300", + "GENERIC": "USE_ETHERNET_GENERIC", + "YT8531": "USE_ETHERNET_YT8531", } @@ -175,9 +179,11 @@ _ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} # ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver) SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60"} -# RP2040-supported ethernet types (SPI and PIO QSPI) -RP2040_ETHERNET_TYPES = {"W5100", "W5500", "W6100", "W6300", "ENC28J60"} -_RP2040_SPI_LIBRARIES = { +# RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole +# RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the +# comment above is about ESP-IDF driver coverage, not the RP2 platform. +RP2_ETHERNET_TYPES = {"W5100", "W5500", "W6100", "W6300", "ENC28J60"} +_RP2_SPI_LIBRARIES = { "W5100": "lwIP_w5100", "W5500": "lwIP_w5500", "ENC28J60": "lwIP_enc28j60", @@ -309,6 +315,24 @@ def _validate(config): f"({CORE.target_framework} {CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]}), " f"'{CONF_INTERRUPT_PIN}' is a required option for [ethernet]." ) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + from esphome.components.esp32 import ( + VARIANT_ESP32S31, + get_esp32_variant, + idf_version, + ) + + eth_type = config[CONF_TYPE] + variant = get_esp32_variant() + if variant != VARIANT_ESP32S31: + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable " + f"variants (ESP32-S31), not {variant}" + ) + if idf_version() < cv.Version(6, 0, 0): + raise cv.Invalid( + f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer." + ) elif config[CONF_TYPE] != "OPENETH": from esphome.components.esp32 import ( VARIANT_ESP32, @@ -324,7 +348,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.7.0.", + "Removal scheduled for 2026.9.0.", config[CONF_CLK_MODE], mode, pin, @@ -339,10 +363,10 @@ def _validate(config): f"{config[CONF_TYPE]} PHY requires RMII interface and is only supported " f"on ESP32 classic and ESP32-P4, not {variant}" ) - elif CORE.is_rp2040 and config[CONF_TYPE] not in RP2040_ETHERNET_TYPES: + elif CORE.is_rp2 and config[CONF_TYPE] not in RP2_ETHERNET_TYPES: raise cv.Invalid( - f"Only {', '.join(sorted(RP2040_ETHERNET_TYPES))} are supported on RP2040, " - f"not {config[CONF_TYPE]}" + f"Only {', '.join(sorted(RP2_ETHERNET_TYPES))} are supported on the RP2 " + f"platform, not {config[CONF_TYPE]}" ) return config @@ -392,6 +416,23 @@ RMII_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) +# Generic IEEE 802.3 PHY over the internal EMAC RGMII interface (e.g. ESP32-S31). +# RGMII data pins come from the IDF per-target default config. +GENERIC_SCHEMA = cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), + } + ) + ), + cv.only_on([Platform.ESP32]), +) + SPI_SCHEMA = cv.All( BASE_SCHEMA.extend( cv.Schema( @@ -420,7 +461,7 @@ SPI_SCHEMA = cv.All( } ), ), - cv.only_on([Platform.ESP32, Platform.RP2040]), + cv.only_on([Platform.ESP32, Platform.RP2]), _validate_spi_interface, ) @@ -434,14 +475,16 @@ CONFIG_SCHEMA = cv.All( "JL1101": RMII_SCHEMA, "KSZ8081": RMII_SCHEMA, "KSZ8081RNA": RMII_SCHEMA, - "W5100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), + "W5100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, "ENC28J60": SPI_SCHEMA, - "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), - "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2040])), + "W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), + "W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])), "LAN8670": RMII_SCHEMA, + "GENERIC": GENERIC_SCHEMA, + "YT8531": GENERIC_SCHEMA, }, upper=True, ), @@ -496,11 +539,11 @@ async def to_code(config): if CORE.is_esp32: await _to_code_esp32(var, config) - elif CORE.is_rp2040: + elif CORE.is_rp2: await _to_code_rp2040(var, config) cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) @@ -540,6 +583,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: @@ -570,6 +614,20 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: elif config[CONF_TYPE] == "OPENETH": cg.add_define("USE_ETHERNET_OPENETH") add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) + elif config[CONF_TYPE] in ("GENERIC", "YT8531"): + # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. + cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) + cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) + cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + if CONF_POWER_PIN in config: + cg.add(var.set_power_pin(config[CONF_POWER_PIN])) + for register_value in config.get(CONF_PHY_REGISTERS, []): + reg = phy_register( + register_value.get(CONF_ADDRESS), + register_value.get(CONF_VALUE), + register_value.get(CONF_PAGE_ID), + ) + cg.add(var.add_phy_register(reg)) else: cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) @@ -586,10 +644,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") @@ -615,7 +672,7 @@ async def _to_code_rp2040(var: cg.Pvariable, config: ConfigType) -> None: cg.add(var.set_reset_pin(config[CONF_RESET_PIN])) cg.add_define("USE_ETHERNET_SPI") - cg.add_library(_RP2040_SPI_LIBRARIES[config[CONF_TYPE]], None) + cg.add_library(_RP2_SPI_LIBRARIES[config[CONF_TYPE]], None) def _final_validate_rmii_pins(config: ConfigType) -> None: @@ -697,7 +754,7 @@ _platform_filter = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, }, - "ethernet_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ethernet_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "esp_eth_phy_jl1101.c": { PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO, diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h index c16abc5bda..f975f52bff 100644 --- a/esphome/components/ethernet/automation.h +++ b/esphome/components/ethernet/automation.h @@ -6,22 +6,22 @@ namespace esphome::ethernet { -template class EthernetConnectedCondition : public Condition { +template class EthernetConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_connected(); } }; -template class EthernetEnabledCondition : public Condition { +template class EthernetEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } }; -template class EthernetEnableAction : public Action { +template class EthernetEnableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->enable(); } }; -template class EthernetDisableAction : public Action { +template class EthernetDisableAction final : public Action { public: void play(const Ts &...x) override { global_eth_component->disable(); } }; diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 7d06377f90..7160351727 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -25,7 +25,7 @@ extern "C" eth_esp32_emac_config_t eth_esp32_emac_default_config(void); #endif #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 #if defined(USE_ETHERNET_W5500) #include #elif defined(USE_ETHERNET_W5100) @@ -86,6 +86,8 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_ENC28J60, ETHERNET_TYPE_W6100, ETHERNET_TYPE_W6300, + ETHERNET_TYPE_GENERIC, + ETHERNET_TYPE_YT8531, }; struct ManualIP { @@ -143,6 +145,8 @@ class EthernetComponent final : public Component { network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } void get_eth_mac_address_raw(uint8_t *mac); @@ -180,14 +184,14 @@ class EthernetComponent final : public Component { #endif // USE_ETHERNET_SPI #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void set_clk_pin(uint8_t clk_pin); void set_miso_pin(uint8_t miso_pin); void set_mosi_pin(uint8_t mosi_pin); void set_cs_pin(uint8_t cs_pin); void set_interrupt_pin(int8_t interrupt_pin); void set_reset_pin(int8_t reset_pin); -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -229,6 +233,11 @@ class EthernetComponent final : public Component { #ifdef USE_ETHERNET_KSZ8081 /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); +#endif +#ifdef USE_ETHERNET_YT8531 + /// @brief Apply YT8531-specific config: re-enable auto-negotiation (disabled on + /// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling. + void yt8531_phy_init_(); #endif /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); @@ -265,7 +274,7 @@ class EthernetComponent final : public Component { esp_eth_phy_t *phy_{nullptr}; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 static constexpr uint32_t LINK_CHECK_INTERVAL = 500; // ms between link/IP polls #if defined(USE_ETHERNET_W5100) static constexpr uint32_t RESET_DELAY_MS = 150; // W5100S PLL lock time @@ -294,7 +303,7 @@ class EthernetComponent final : public Component { uint8_t cs_pin_; int8_t interrupt_pin_{-1}; int8_t reset_pin_{-1}; -#endif // USE_RP2040 +#endif // USE_RP2 // Common members #ifdef USE_ETHERNET_MANUAL_IP @@ -339,7 +348,7 @@ class EthernetComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 544ec79c32..5ad1e7d483 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -254,9 +254,14 @@ void EthernetComponent::ethernet_lazy_init_() { esp32_emac_config.smi_mdc_gpio_num = this->mdc_pin_; esp32_emac_config.smi_mdio_gpio_num = this->mdio_pin_; #endif - esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; - esp32_emac_config.clock_config.rmii.clock_gpio = - static_cast(this->clk_pin_); + // The RGMII types (GENERIC, YT8531) use the RGMII interface and default GPIO map from + // eth_esp32_emac_default_config(); writing the RMII clock config would clobber that + // union, so skip the RMII clock override for them. + if (this->type_ != ETHERNET_TYPE_GENERIC && this->type_ != ETHERNET_TYPE_YT8531) { + esp32_emac_config.clock_config.rmii.clock_mode = this->clk_mode_; + esp32_emac_config.clock_config.rmii.clock_gpio = + static_cast(this->clk_pin_); + } esp_eth_mac_t *mac = esp_eth_mac_new_esp32(&esp32_emac_config, &mac_config); #endif @@ -319,6 +324,20 @@ void EthernetComponent::ethernet_lazy_init_() { break; } #endif +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // GENERIC and YT8531 both use the built-in generic 802.3 PHY driver; YT8531 gets + // extra chip-specific tuning applied later in ethernet_lazy_init_(). +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: +#endif +#if defined(USE_ETHERNET_GENERIC) || defined(USE_ETHERNET_YT8531) + this->phy_ = esp_eth_phy_new_generic(&phy_config); + break; +#endif +#endif #endif #ifdef USE_ETHERNET_SPI #if defined(USE_ETHERNET_W5500) @@ -363,7 +382,30 @@ void EthernetComponent::ethernet_lazy_init_() { for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#ifdef USE_ETHERNET_GENERIC + // The generic 802.3 PHY driver only resets the PHY in its init; it never enables + // auto-negotiation. A PHY that resets into a forced-speed mode (BMCR auto-nego bit + // clear) therefore stays there, and esp_eth_start() skips negotiation because the + // driver cached auto_nego_en=false at install time. Force auto-negotiation on here + // (which also updates that cached state) so esp_eth_start() restarts a proper + // negotiation. (YT8531 does this as part of its own chip-specific init below.) + if (this->type_ == ETHERNET_TYPE_GENERIC) { + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "Enable auto-negotiation failed"); + } #endif +#ifdef USE_ETHERNET_YT8531 + if (this->type_ == ETHERNET_TYPE_YT8531) { + this->yt8531_phy_init_(); + if (this->is_failed()) + return; + } +#endif +#endif // ESP_IDF_VERSION >= 6.0.0 +#endif // !USE_ETHERNET_SPI // use ESP internal eth mac uint8_t mac_addr[6]; @@ -486,6 +528,16 @@ void EthernetComponent::dump_config() { eth_type = "LAN8670"; break; #endif +#ifdef USE_ETHERNET_GENERIC + case ETHERNET_TYPE_GENERIC: + eth_type = "Generic (RGMII)"; + break; +#endif +#ifdef USE_ETHERNET_YT8531 + case ETHERNET_TYPE_YT8531: + eth_type = "YT8531 (RGMII)"; + break; +#endif default: eth_type = "Unknown"; @@ -782,6 +834,19 @@ void EthernetComponent::dump_connect_params_() { char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint16_t link_speed = 10; + switch (this->get_link_speed()) { + case ETH_SPEED_100M: + link_speed = 100; + break; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + case ETH_SPEED_1000M: + link_speed = 1000; + break; +#endif + default: + break; + } ESP_LOGCONFIG(TAG, " IP Address: %s\n" " Hostname: '%s'\n" @@ -796,7 +861,7 @@ void EthernetComponent::dump_connect_params_() { network::IPAddress(&ip.netmask).str_to(subnet_buf), network::IPAddress(&ip.gw).str_to(gateway_buf), network::IPAddress(dns_ip1).str_to(dns1_buf), network::IPAddress(dns_ip2).str_to(dns2_buf), this->get_eth_mac_address_pretty_into_buffer(mac_buf), - YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), this->get_link_speed() == ETH_SPEED_100M ? 100 : 10); + YESNO(this->get_duplex_mode() == ETH_DUPLEX_FULL), link_speed); #if USE_NETWORK_IPV6 struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; @@ -958,6 +1023,50 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi #endif } +#ifdef USE_ETHERNET_YT8531 +void EthernetComponent::yt8531_phy_init_() { + esp_err_t err; + + // The YT8531 disables auto-negotiation on hardware reset (undocumented behavior), and the + // generic 802.3 driver only resets the PHY, so re-enable it (this also updates the driver's + // cached auto-nego state used by esp_eth_start()). + bool autoneg_enable = true; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_S_AUTONEGO, &autoneg_enable); + ESPHL_ERROR_CHECK(err, "YT8531 enable auto-negotiation failed"); + + // RGMII needs ~2 ns Tx and Rx clock delays for reliable data sampling. These are set through + // the YT8531 extended-register interface: write the ext-register address to 0x1E, then + // read/modify/write its value via 0x1F. + esp_eth_phy_reg_rw_data_t phy_reg; + uint32_t reg_val; + phy_reg.reg_value_p = ®_val; + + // RX ~2 ns coarse delay: EXT_CHIP_CONFIG (0xA001), set rxc_dly_en (bit 8). + reg_val = 0xA001; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select Chip_Config failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read Chip_Config failed"); + reg_val |= (1U << 8); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write Chip_Config failed"); + + // TX ~2 ns delay: EXT_RGMII_CONFIG1 (0xA003), tx_delay_sel[3:0] and tx_delay_sel_fe[7:4] = 13. + reg_val = 0xA003; + phy_reg.reg_addr = 0x1E; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 select RGMII_Config1 failed"); + phy_reg.reg_addr = 0x1F; + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_READ_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 read RGMII_Config1 failed"); + reg_val = (reg_val & ~0x00FFU) | (13U << 4) | (13U << 0); + err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_WRITE_PHY_REG, &phy_reg); + ESPHL_ERROR_CHECK(err, "YT8531 write RGMII_Config1 failed"); +} +#endif + #endif } // namespace esphome::ethernet diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp similarity index 98% rename from esphome/components/ethernet/ethernet_component_rp2040.cpp rename to esphome/components/ethernet/ethernet_component_rp2.cpp index 250297ddb5..d2e3f14e02 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -1,12 +1,12 @@ #include "ethernet_component.h" -#if defined(USE_ETHERNET) && defined(USE_RP2040) +#if defined(USE_ETHERNET) && defined(USE_RP2) #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/components/rp2040/gpio.h" +#include "esphome/components/rp2/gpio.h" #include #include @@ -29,7 +29,7 @@ void EthernetComponent::setup() { // Toggle reset pin if configured if (this->reset_pin_ >= 0) { - rp2040::RP2040GPIOPin reset_pin; + rp2::RP2GPIOPin reset_pin; reset_pin.set_pin(this->reset_pin_); reset_pin.set_flags(gpio::FLAG_OUTPUT); reset_pin.setup(); @@ -380,4 +380,4 @@ void EthernetComponent::disable() { } // namespace esphome::ethernet -#endif // USE_ETHERNET && USE_RP2040 +#endif // USE_ETHERNET && USE_RP2 diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 4cab1bff9b..e205e4b910 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -50,7 +50,9 @@ _EVENT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent), cv.GenerateID(): cv.declare_id(Event), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_ON_EVENT): automation.validate_automation({}), } ) diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h index 3444a7b1bb..73a6336f78 100644 --- a/esphome/components/event/automation.h +++ b/esphome/components/event/automation.h @@ -6,14 +6,14 @@ namespace esphome::event { -template class TriggerEventAction : public Action, public Parented { +template class TriggerEventAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, event_type) void play(const Ts &...x) override { this->parent_->trigger(this->event_type_.value(x...)); } }; -class EventTrigger : public Trigger { +class EventTrigger final : public Trigger { public: EventTrigger(Event *event) { event->add_on_event_callback([this](StringRef event_type) { this->trigger(event_type); }); diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 80184f9cfd..6a703a9a92 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -16,8 +16,8 @@ struct ExposureNotification { std::array associated_encrypted_metadata; }; -class ExposureNotificationTrigger : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { +class ExposureNotificationTrigger final : public Trigger, + public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index aea276e001..a20419eceb 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -32,7 +32,7 @@ class EzoCommand { }; /// This class implements support for the EZO circuits in i2c mode -class EZOSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class EZOSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void loop() override; void dump_config() override; diff --git a/esphome/components/ezo_pmp/ezo_pmp.h b/esphome/components/ezo_pmp/ezo_pmp.h index 8a6da5fe74..55283f2d09 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.h +++ b/esphome/components/ezo_pmp/ezo_pmp.h @@ -19,7 +19,7 @@ namespace esphome::ezo_pmp { -class EzoPMP : public PollingComponent, public i2c::I2CDevice { +class EzoPMP final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; @@ -114,7 +114,7 @@ class EzoPMP : public PollingComponent, public i2c::I2CDevice { }; // Action Templates -template class EzoPMPFindAction : public Action { +template class EzoPMPFindAction final : public Action { public: EzoPMPFindAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -124,7 +124,7 @@ template class EzoPMPFindAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseContinuouslyAction : public Action { +template class EzoPMPDoseContinuouslyAction final : public Action { public: EzoPMPDoseContinuouslyAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -134,7 +134,7 @@ template class EzoPMPDoseContinuouslyAction : public Action class EzoPMPDoseVolumeAction : public Action { +template class EzoPMPDoseVolumeAction final : public Action { public: EzoPMPDoseVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -145,7 +145,7 @@ template class EzoPMPDoseVolumeAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPDoseVolumeOverTimeAction : public Action { +template class EzoPMPDoseVolumeOverTimeAction final : public Action { public: EzoPMPDoseVolumeOverTimeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -159,7 +159,7 @@ template class EzoPMPDoseVolumeOverTimeAction : public Action class EzoPMPDoseWithConstantFlowRateAction : public Action { +template class EzoPMPDoseWithConstantFlowRateAction final : public Action { public: EzoPMPDoseWithConstantFlowRateAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -173,7 +173,7 @@ template class EzoPMPDoseWithConstantFlowRateAction : public Act EzoPMP *ezopmp_; }; -template class EzoPMPSetCalibrationVolumeAction : public Action { +template class EzoPMPSetCalibrationVolumeAction final : public Action { public: EzoPMPSetCalibrationVolumeAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -184,7 +184,7 @@ template class EzoPMPSetCalibrationVolumeAction : public Action< EzoPMP *ezopmp_; }; -template class EzoPMPClearTotalVolumeDispensedAction : public Action { +template class EzoPMPClearTotalVolumeDispensedAction final : public Action { public: EzoPMPClearTotalVolumeDispensedAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -194,7 +194,7 @@ template class EzoPMPClearTotalVolumeDispensedAction : public Ac EzoPMP *ezopmp_; }; -template class EzoPMPClearCalibrationAction : public Action { +template class EzoPMPClearCalibrationAction final : public Action { public: EzoPMPClearCalibrationAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -204,7 +204,7 @@ template class EzoPMPClearCalibrationAction : public Action class EzoPMPPauseDosingAction : public Action { +template class EzoPMPPauseDosingAction final : public Action { public: EzoPMPPauseDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -214,7 +214,7 @@ template class EzoPMPPauseDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPStopDosingAction : public Action { +template class EzoPMPStopDosingAction final : public Action { public: EzoPMPStopDosingAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -224,7 +224,7 @@ template class EzoPMPStopDosingAction : public Action { EzoPMP *ezopmp_; }; -template class EzoPMPChangeI2CAddressAction : public Action { +template class EzoPMPChangeI2CAddressAction final : public Action { public: EzoPMPChangeI2CAddressAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} @@ -235,7 +235,7 @@ template class EzoPMPChangeI2CAddressAction : public Action class EzoPMPArbitraryCommandAction : public Action { +template class EzoPMPArbitraryCommandAction final : public Action { public: EzoPMPArbitraryCommandAction(EzoPMP *ezopmp) : ezopmp_(ezopmp) {} diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index a8cb897614..0bb8a62f5e 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -7,7 +7,7 @@ namespace esphome::factory_reset { -class FactoryResetButton : public button::Button, public Component { +class FactoryResetButton final : public button::Button, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index cd4134e9ae..bceaf6e40f 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -7,7 +7,7 @@ #include -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) namespace esphome::factory_reset { @@ -73,4 +73,4 @@ void FactoryResetComponent::setup() { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/factory_reset.h b/esphome/components/factory_reset/factory_reset.h index 41ee627c4b..b0a899c719 100644 --- a/esphome/components/factory_reset/factory_reset.h +++ b/esphome/components/factory_reset/factory_reset.h @@ -3,14 +3,14 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" #include "esphome/core/preferences.h" -#if !defined(USE_RP2040) && !defined(USE_HOST) +#if !defined(USE_RP2) && !defined(USE_HOST) #ifdef USE_ESP32 #include #endif namespace esphome::factory_reset { -class FactoryResetComponent : public Component { +class FactoryResetComponent final : public Component { public: FactoryResetComponent(uint8_t required_count, uint16_t max_interval) : max_interval_(max_interval), required_count_(required_count) {} @@ -32,4 +32,4 @@ class FactoryResetComponent : public Component { } // namespace esphome::factory_reset -#endif // !defined(USE_RP2040) && !defined(USE_HOST) +#endif // !defined(USE_RP2) && !defined(USE_HOST) diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index be80356b31..fb76b10cf3 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -6,7 +6,7 @@ namespace esphome::factory_reset { -class FactoryResetSwitch : public switch_::Switch, public Component { +class FactoryResetSwitch final : public switch_::Switch, public Component { public: void dump_config() override; #ifdef USE_OPENTHREAD diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 964ebe77a0..cbd994e749 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -18,7 +18,7 @@ namespace esphome::fan { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} @@ -33,7 +33,7 @@ template class TurnOnAction : public Action { ApplyFn apply_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Fan *state) : state_(state) {} @@ -42,7 +42,7 @@ template class TurnOffAction : public Action { Fan *state_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Fan *state) : state_(state) {} @@ -51,7 +51,7 @@ template class ToggleAction : public Action { Fan *state_; }; -template class CycleSpeedAction : public Action { +template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} @@ -95,7 +95,7 @@ template class CycleSpeedAction : public Action { Fan *state_; }; -template class FanIsOnCondition : public Condition { +template class FanIsOnCondition final : public Condition { public: explicit FanIsOnCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->state; } @@ -103,7 +103,7 @@ template class FanIsOnCondition : public Condition { protected: Fan *state_; }; -template class FanIsOffCondition : public Condition { +template class FanIsOffCondition final : public Condition { public: explicit FanIsOffCondition(Fan *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->state; } @@ -112,7 +112,7 @@ template class FanIsOffCondition : public Condition { Fan *state_; }; -class FanStateTrigger : public Trigger { +class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { this->trigger(this->fan_); }); @@ -122,7 +122,7 @@ class FanStateTrigger : public Trigger { Fan *fan_; }; -class FanTurnOnTrigger : public Trigger<> { +class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -141,7 +141,7 @@ class FanTurnOnTrigger : public Trigger<> { bool last_on_; }; -class FanTurnOffTrigger : public Trigger<> { +class FanTurnOffTrigger final : public Trigger<> { public: FanTurnOffTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -160,7 +160,7 @@ class FanTurnOffTrigger : public Trigger<> { bool last_on_; }; -class FanDirectionSetTrigger : public Trigger { +class FanDirectionSetTrigger final : public Trigger { public: FanDirectionSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -179,7 +179,7 @@ class FanDirectionSetTrigger : public Trigger { FanDirection last_direction_; }; -class FanOscillatingSetTrigger : public Trigger { +class FanOscillatingSetTrigger final : public Trigger { public: FanOscillatingSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -198,7 +198,7 @@ class FanOscillatingSetTrigger : public Trigger { bool last_oscillating_; }; -class FanSpeedSetTrigger : public Trigger { +class FanSpeedSetTrigger final : public Trigger { public: FanSpeedSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { @@ -217,7 +217,7 @@ class FanSpeedSetTrigger : public Trigger { int last_speed_; }; -class FanPresetSetTrigger : public Trigger { +class FanPresetSetTrigger final : public Trigger { public: FanPresetSetTrigger(Fan *state) : fan_(state) { state->add_on_state_callback([this]() { diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index f8535eb628..1261b742a1 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -17,7 +17,7 @@ namespace esphome::fastled_base { -class FastLEDLightOutput : public light::AddressableLight { +class FastLEDLightOutput final : public light::AddressableLight { public: /// Only for custom effects: Get the internal controller. CLEDController *get_controller() const { return this->controller_; } diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index ed6f7490f8..3e4600acd2 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -10,7 +10,7 @@ namespace esphome::feedback { -class FeedbackCover : public cover::Cover, public Component { +class FeedbackCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py new file mode 100644 index 0000000000..f70ffa9520 --- /dev/null +++ b/esphome/components/file/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py new file mode 100644 index 0000000000..9a7c762a79 --- /dev/null +++ b/esphome/components/file/image.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import logging +from pathlib import Path +import re + +from PIL import Image, UnidentifiedImageError + +from esphome import core, external_files +import esphome.codegen as cg +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.image import ( + CONF_INVERT_ALPHA, + CONF_OPAQUE, + CONF_TRANSPARENCY, + DOMAIN, + IMAGE_TYPE, + Image_, + ImageEncoder, + add_metadata, + get_image_type_enum, + get_transparency_enum, + is_svg_file, + validate_settings, + validate_transparency, + validate_type, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ICON, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_URL, +) +from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +_LOGGER = logging.getLogger(__name__) + +# If the MDI file cannot be downloaded within this time, abort. +IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds + +SOURCE_LOCAL = "local" +SOURCE_WEB = "web" + +SOURCE_MDI = "mdi" +SOURCE_MDIL = "mdil" +SOURCE_MEMORY = "memory" + +MDI_SOURCES = { + SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", + SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", + SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", +} + + +def compute_local_image_path(value) -> Path: + url = value[CONF_URL] if isinstance(value, dict) else value + h = hashlib.new("sha256") + h.update(url.encode()) + key = h.hexdigest()[:8] + # Downloaded files are cached under the shared `image` domain directory so + # the cache location is unaffected by which platform requested the file. + base_dir = external_files.compute_local_file_dir(DOMAIN) + return base_dir / key + + +def local_path(value): + value = value[CONF_PATH] if isinstance(value, dict) else value + return str(CORE.relative_config_path(value)) + + +def download_file(url, path): + external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + return str(path) + + +def download_gh_svg(value, source): + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + base_dir = external_files.compute_local_file_dir(DOMAIN) / source + path = base_dir / f"{mdi_id}.svg" + + url = MDI_SOURCES[source] + mdi_id + ".svg" + return download_file(url, path) + + +def download_image(value): + value = value[CONF_URL] if isinstance(value, dict) else value + return download_file(value, compute_local_image_path(value)) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + parts = value.strip().split(":") + if len(parts) == 2 and parts[0] in MDI_SOURCES: + match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) + if match is None: + raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") + return download_gh_svg(parts[1], parts[0]) + + if value.startswith(("http://", "https://")): + return download_image(value) + + value = cv.file_(value) + return local_path(value) + + +LOCAL_SCHEMA = cv.All( + { + cv.Required(CONF_PATH): cv.file_, + }, + local_path, +) + + +def mdi_schema(source): + def validate_mdi(value): + return download_gh_svg(value, source) + + return cv.All( + cv.Schema( + { + cv.Required(CONF_ICON): cv.string, + } + ), + validate_mdi, + ) + + +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.string, + }, + download_image, +) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + SOURCE_LOCAL: LOCAL_SCHEMA, + SOURCE_WEB: WEB_SCHEMA, + } + | {source: mdi_schema(source) for source in MDI_SOURCES}, + key=CONF_SOURCE, +) + + +OPTIONS_SCHEMA = { + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, + cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), +} + + +def image_schema(class_: MockObjClass = Image_) -> cv.Schema: + """Build the validation schema for a single file-backed image entry. + + Shared by the built-in ``file`` image platform and the ``animation`` + platform (which extends it). Platforms that source their pixels elsewhere + (e.g. ``online_image``) provide their own schema instead. + + :param class_: The declared C++ class for the generated image instance. + """ + return cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(class_), + cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + **OPTIONS_SCHEMA, + cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE), + } + ) + + +def validate_image_final(config: ConfigType) -> ConfigType: + """Per-entry final validation, shared by file-backed image platforms. + + For LVGL 9 the default byte order for RGB565 images is little-endian, so + fill in that default when the user did not specify a byte order and warn + when big-endian was explicitly requested. + """ + if byte_order := config.get(CONF_BYTE_ORDER): + if byte_order == "BIG_ENDIAN": + _LOGGER.warning( + "The image '%s' is configured with big-endian byte order, little-endian is expected", + config.get(CONF_FILE), + ) + else: + config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" + return config + + +async def new_image(config: ConfigType) -> MockObj: + """Generate a single file-backed ``image::Image`` instance. + + Used by the built-in ``file`` platform; encodes the image data, registers + the C++ variable and records its metadata for other components to consume. + """ + prog_arr, width, height, image_type, trans_value, _ = await write_image(config) + var = cg.new_Pvariable( + config[CONF_ID], prog_arr, width, height, image_type, trans_value + ) + add_metadata( + config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY] + ) + return var + + +async def write_image(config, all_frames=False): + path = Path(config[CONF_FILE]) + if not path.is_file(): + raise core.EsphomeError(f"Could not load image file {path}") + + resize = config.get(CONF_RESIZE) + try: + if is_svg_file(path): + import resvg_py + + resize = resize or (None, None) + image_data = resvg_py.svg_to_bytes( + svg_path=str(path), width=resize[0], height=resize[1], dpi=100 + ) + + # Convert bytes to Pillow Image + image = Image.open(io.BytesIO(image_data)) + width, height = image.size + + else: + image = Image.open(path) + width, height = image.size + if resize: + # Preserve aspect ratio + new_width_max = min(width, resize[0]) + new_height_max = min(height, resize[1]) + ratio = min(new_width_max / width, new_height_max / height) + width, height = int(width * ratio), int(height * ratio) + except (OSError, UnidentifiedImageError, ValueError) as exc: + raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc + + if not resize and (width > 500 or height > 500): + _LOGGER.warning( + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, + ) + + dither = ( + Image.Dither.NONE + if config[CONF_DITHER] == "NONE" + else Image.Dither.FLOYDSTEINBERG + ) + type = config[CONF_TYPE] + transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) + invert_alpha = config[CONF_INVERT_ALPHA] + frame_count = 1 + if all_frames: + with contextlib.suppress(AttributeError): + frame_count = image.n_frames + if frame_count <= 1: + _LOGGER.warning("Image file %s has no animation frames", path) + + # Encode each frame with its own encoder and concatenate. This keeps every + # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] + # per frame) so animation frame stepping in image.cpp / animation.cpp stays + # correct without needing to know the total frame count. + byte_order = config.get(CONF_BYTE_ORDER) + combined_data: list[int] = [] + encoder: ImageEncoder | None = None + for frame_index in range(frame_count): + image.seek(frame_index) + encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) + if byte_order is not None: + # Check for valid type has already been done in validate_settings + encoder.set_big_endian(byte_order == "BIG_ENDIAN") + pixels = encoder.convert(image.resize((width, height)), path).getdata() + for row in range(height): + for col in range(width): + encoder.encode(pixels[row * width + col]) + encoder.end_row() + encoder.end_image() + combined_data.extend(encoder.data) + + rhs = [HexInt(x) for x in combined_data] + prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) + image_type = get_image_type_enum(type) + trans_value = get_transparency_enum(encoder.transparency) + + return prog_arr, width, height, image_type, trans_value, frame_count + + +# The built-in static-image platform: pixels embedded at compile time from a +# local file, a downloaded web image, or a Material Design Icon. +CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings) + +FINAL_VALIDATE_SCHEMA = validate_image_final + + +async def to_code(config: ConfigType) -> None: + await new_image(config) diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.h b/esphome/components/fingerprint_grow/fingerprint_grow.h index 7cecb7dc82..67662192ee 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.h +++ b/esphome/components/fingerprint_grow/fingerprint_grow.h @@ -92,7 +92,7 @@ enum GrowAuraLEDColor { WHITE = 0x07, }; -class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevice { +class FingerprintGrowComponent final : public PollingComponent, public uart::UARTDevice { public: void update() override; void setup() override; @@ -209,7 +209,8 @@ class FingerprintGrowComponent : public PollingComponent, public uart::UARTDevic CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template +class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) TEMPLATABLE_VALUE(uint8_t, num_scans) @@ -226,12 +227,12 @@ template class EnrollmentAction : public Action, public P }; template -class CancelEnrollmentAction : public Action, public Parented { +class CancelEnrollmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish_enrollment(1); } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, finger_id) @@ -241,12 +242,13 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_fingerprints(); } }; -template class LEDControlAction : public Action, public Parented { +template +class LEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -256,7 +258,8 @@ template class LEDControlAction : public Action, public P } }; -template class AuraLEDControlAction : public Action, public Parented { +template +class AuraLEDControlAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) TEMPLATABLE_VALUE(uint8_t, speed) diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 9c9cfa0f6d..fa24181bd0 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -14,7 +14,7 @@ namespace esphome::font { class Font; -class Glyph { +class Glyph final { public: constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, int height) @@ -37,7 +37,7 @@ class Glyph { int height; }; -class Font +class Font final #ifdef USE_DISPLAY : public display::BaseFont #endif diff --git a/esphome/components/fs3000/fs3000.h b/esphome/components/fs3000/fs3000.h index c019b1366b..e98e72fa8e 100644 --- a/esphome/components/fs3000/fs3000.h +++ b/esphome/components/fs3000/fs3000.h @@ -11,7 +11,7 @@ namespace esphome::fs3000 { // 1015 has a max speed detection of 15 m/s enum FS3000Model { FIVE, FIFTEEN }; -class FS3000Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class FS3000Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void update() override; diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h index 7cf8769f7a..d788b2044c 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.h @@ -34,7 +34,7 @@ enum FTMode : uint8_t { static const size_t MAX_TOUCHES = 5; // max number of possible touches reported -class FT5x06Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class FT5x06Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ft63x6/ft63x6.h b/esphome/components/ft63x6/ft63x6.h index efa03168d9..5e72cf77d8 100644 --- a/esphome/components/ft63x6/ft63x6.h +++ b/esphome/components/ft63x6/ft63x6.h @@ -17,7 +17,7 @@ using namespace touchscreen; static const uint8_t FT6X36_DEFAULT_THRESHOLD = 22; -class FT63X6Touchscreen : public Touchscreen, public i2c::I2CDevice { +class FT63X6Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index ca93e4b300..8d2ec883da 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -46,7 +46,7 @@ const uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius */ // clang-format on -class FujitsuGeneralClimate : public climate_ir::ClimateIR { +class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() : ClimateIR(FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/gcja5/gcja5.h b/esphome/components/gcja5/gcja5.h index 30c9464b4a..f25d864f1a 100644 --- a/esphome/components/gcja5/gcja5.h +++ b/esphome/components/gcja5/gcja5.h @@ -7,7 +7,7 @@ namespace esphome::gcja5 { -class GCJA5Component : public Component, public uart::UARTDevice { +class GCJA5Component final : public Component, public uart::UARTDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/gdk101/gdk101.h b/esphome/components/gdk101/gdk101.h index 2ef7526294..5a91594081 100644 --- a/esphome/components/gdk101/gdk101.h +++ b/esphome/components/gdk101/gdk101.h @@ -22,7 +22,7 @@ static const uint8_t GDK101_REG_READ_MEASURING_TIME = 0xB1; // Mesuring time static const uint8_t GDK101_REG_READ_10MIN_AVG = 0xB2; // Average radiation dose per 10 min static const uint8_t GDK101_REG_READ_1MIN_AVG = 0xB3; // Average radiation dose per 1 min -class GDK101Component : public PollingComponent, public i2c::I2CDevice { +class GDK101Component final : public PollingComponent, public i2c::I2CDevice { #ifdef USE_SENSOR SUB_SENSOR(rad_1m) SUB_SENSOR(rad_10m) diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.h b/esphome/components/gl_r01_i2c/gl_r01_i2c.h index 1d023c245a..23a1dec336 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.h +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.h @@ -6,7 +6,7 @@ namespace esphome::gl_r01_i2c { -class GLR01I2CComponent : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { +class GLR01I2CComponent final : public sensor::Sensor, public i2c::I2CDevice, public PollingComponent { public: void setup() override; void dump_config() override; diff --git a/esphome/components/globals/globals_component.h b/esphome/components/globals/globals_component.h index 520c068e6f..78d2bc5910 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -7,7 +7,7 @@ namespace esphome::globals { -template class GlobalsComponent : public Component { +template class GlobalsComponent final : public Component { public: using value_type = T; explicit GlobalsComponent() = default; @@ -127,7 +127,7 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction : public Action { +template class GlobalVarSetAction final : public Action { public: explicit GlobalVarSetAction(C *parent) : parent_(parent) {} diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.h b/esphome/components/gp2y1010au0f/gp2y1010au0f.h index f3398ac4a3..648e66d2ff 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.h +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.h @@ -7,7 +7,7 @@ namespace esphome::gp2y1010au0f { -class GP2Y1010AU0FSensor : public sensor::Sensor, public PollingComponent { +class GP2Y1010AU0FSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void loop() override; diff --git a/esphome/components/gp8403/gp8403.h b/esphome/components/gp8403/gp8403.h index d30d967479..5d969c20d2 100644 --- a/esphome/components/gp8403/gp8403.h +++ b/esphome/components/gp8403/gp8403.h @@ -15,7 +15,7 @@ enum GP8403Model : uint8_t { GP8413, }; -class GP8403Component : public Component, public i2c::I2CDevice { +class GP8403Component final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gp8403/output/gp8403_output.h b/esphome/components/gp8403/output/gp8403_output.h index 8b1f920680..ea3b7cd6f6 100644 --- a/esphome/components/gp8403/output/gp8403_output.h +++ b/esphome/components/gp8403/output/gp8403_output.h @@ -7,7 +7,7 @@ namespace esphome::gp8403 { -class GP8403Output : public Component, public output::FloatOutput, public Parented { +class GP8403Output final : public Component, public output::FloatOutput, public Parented { public: void dump_config() override; float get_setup_priority() const override { return setup_priority::DATA - 1; } diff --git a/esphome/components/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index f14a920c24..43358baedb 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -39,7 +39,6 @@ CONFIG_SCHEMA = ( # due to hardware limitations or lack of reliable interrupt support. This ensures # stable operation on these platforms. Future maintainers should verify platform # capabilities before changing this default behavior. - # nrf52 has no gpio interrupts implemented yet cv.SplitDefault( CONF_USE_INTERRUPT, bk72xx=False, @@ -47,8 +46,8 @@ CONFIG_SCHEMA = ( esp8266=True, host=True, ln882x=False, - nrf52=False, - rp2040=True, + nrf52=True, + rp2=True, rtl87xx=False, ): cv.boolean, cv.Optional(CONF_INTERRUPT_TYPE, default="ANY"): cv.enum( diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.h b/esphome/components/gpio/one_wire/gpio_one_wire.h index 02797b5737..e457b599e5 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.h +++ b/esphome/components/gpio/one_wire/gpio_one_wire.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOOneWireBus : public one_wire::OneWireBus, public Component { +class GPIOOneWireBus final : public one_wire::OneWireBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 4100cb94c2..496afd131b 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -6,7 +6,7 @@ namespace esphome::gpio { -class GPIOBinaryOutput : public output::BinaryOutput, public Component { +class GPIOBinaryOutput final : public output::BinaryOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } diff --git a/esphome/components/gps/gps.h b/esphome/components/gps/gps.h index 9cd79e25b4..7771286edf 100644 --- a/esphome/components/gps/gps.h +++ b/esphome/components/gps/gps.h @@ -22,7 +22,7 @@ class GPSListener { GPS *parent_; }; -class GPS : public PollingComponent, public uart::UARTDevice { +class GPS final : public PollingComponent, public uart::UARTDevice { public: void set_latitude_sensor(sensor::Sensor *latitude_sensor) { this->latitude_sensor_ = latitude_sensor; } void set_longitude_sensor(sensor::Sensor *longitude_sensor) { this->longitude_sensor_ = longitude_sensor; } diff --git a/esphome/components/gps/time/gps_time.h b/esphome/components/gps/time/gps_time.h index 3d6d870efc..bd2049c46c 100644 --- a/esphome/components/gps/time/gps_time.h +++ b/esphome/components/gps/time/gps_time.h @@ -6,7 +6,7 @@ namespace esphome::gps { -class GPSTime : public time::RealTimeClock, public GPSListener { +class GPSTime final : public time::RealTimeClock, public GPSListener { public: void update() override { this->from_tiny_gps_(this->get_tiny_gps()); }; void on_update(TinyGPSPlus &tiny_gps) override { diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 5d59f60509..9ceb2f2ba0 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -139,7 +139,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo /// Draw grid if (!std::isnan(this->gridspacing_y_)) { for (int y = yn; y <= ym; y++) { - int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0 - (float) (y - yn) / (ym - yn))); + int16_t py = (int16_t) roundf((this->height_ - 1) * (1.0f - (float) (y - yn) / (ym - yn))); for (uint32_t x = 0; x < this->width_; x += 2) { buff->draw_pixel_at(x_offset + x, y_offset + py, color); } @@ -177,7 +177,7 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo uint8_t bit = 1 << ((i % (thick * LineType::PATTERN_LENGTH)) / thick); bool b = (trace->get_line_type() & bit) == bit; if (b) { - int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0 - v)) - thick / 2 + y_offset; + int16_t y = (int16_t) roundf((this->height_ - 1) * (1.0f - v)) - thick / 2 + y_offset; auto draw_pixel_at = [&buff, c, y_offset, this](int16_t x, int16_t y) { if (y >= y_offset && static_cast(y) < y_offset + this->height_) buff->draw_pixel_at(x, y, c); diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h index a601e9eeb1..dbedab6085 100644 --- a/esphome/components/graph/graph.h +++ b/esphome/components/graph/graph.h @@ -42,7 +42,7 @@ enum ValuePositionType { VALUE_POSITION_TYPE_BELOW }; -class GraphLegend { +class GraphLegend final { public: void init(Graph *g); void set_name_font(display::BaseFont *font) { this->font_label_ = font; } @@ -105,7 +105,7 @@ class HistoryData { std::vector samples_; }; -class GraphTrace { +class GraphTrace final { public: void init(Graph *g); void set_name(std::string name) { name_ = std::move(name); } @@ -134,7 +134,7 @@ class GraphTrace { friend GraphLegend; }; -class Graph : public Component { +class Graph final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); void draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color); diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index 81971e457c..b3c3b27e06 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -118,7 +118,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const for (size_t i = 0; max_item_index >= 0 && i <= static_cast(max_item_index); i++) { const auto *item = this->displayed_item_->get_item(i); const bool selected = i == this->cursor_index_; - const display::Rect item_dimensions = this->measure_item(display, item, bounds, selected); + const display::Rect item_dimensions = this->measure_item_(display, item, bounds, selected); menu_dimensions.push_back(item_dimensions); total_height += item_dimensions.h + (i == 0 ? 0 : y_padding); @@ -181,7 +181,7 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const dimensions.y = y_offset; dimensions.x = bounds->x; - this->draw_item(display, item, &dimensions, selected); + this->draw_item_(display, item, &dimensions, selected); y_offset += dimensions.h + y_padding; } @@ -189,8 +189,8 @@ void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const display->end_clipping(); } -display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +display::Rect GraphicalDisplayMenu::measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { display::Rect dimensions(0, 0, 0, 0); if (selected) { @@ -218,8 +218,8 @@ display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, cons return dimensions; } -inline void GraphicalDisplayMenu::draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, const bool selected) { +inline void GraphicalDisplayMenu::draw_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, const bool selected) { const auto background_color = selected ? this->foreground_color_ : this->background_color_; const auto foreground_color = selected ? this->background_color_ : this->foreground_color_; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ce1db18525..ccdf3d304c 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -33,7 +33,7 @@ struct MenuItemValueArguments { bool is_menu_editing; }; -class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { +class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponent { public: void setup() override; void dump_config() override; @@ -53,10 +53,10 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { void draw_menu() override; void draw_menu_internal_(display::Display *display, const display::Rect *bounds); void draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) override; - virtual display::Rect measure_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); - virtual void draw_item(display::Display *display, const display_menu_base::MenuItem *item, - const display::Rect *bounds, bool selected); + display::Rect measure_item_(display::Display *display, const display_menu_base::MenuItem *item, + const display::Rect *bounds, bool selected); + void draw_item_(display::Display *display, const display_menu_base::MenuItem *item, const display::Rect *bounds, + bool selected); void update() override; void on_before_show() override; @@ -73,7 +73,7 @@ class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { CallbackManager on_redraw_callbacks_{}; }; -class GraphicalDisplayMenuOnRedrawTrigger : public Trigger { +class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { public: explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 1eb812ae46..2f10be3e6b 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -79,7 +79,7 @@ static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; -class GreeClimate : public climate_ir::ClimateIR { +class GreeClimate final : public climate_ir::ClimateIR { public: GreeClimate() : climate_ir::ClimateIR(GREE_TEMP_MIN, GREE_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h index 9d9f187f9d..1e82c83ae6 100644 --- a/esphome/components/gree/switch/gree_switch.h +++ b/esphome/components/gree/switch/gree_switch.h @@ -6,7 +6,7 @@ namespace esphome::gree { -class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { +class GreeModeBitSwitch final : public switch_::Switch, public Component, public Parented { public: GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {} diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h index 38165ab68c..545b6df97a 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.h @@ -7,7 +7,7 @@ namespace esphome::grove_gas_mc_v2 { -class GroveGasMultichannelV2Component : public PollingComponent, public i2c::I2CDevice { +class GroveGasMultichannelV2Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(tvoc) SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index eaa1440c4d..2c68eef623 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -122,7 +122,7 @@ void GroveMotorDriveTB6612FNG::stepper_run(StepperModeTypeT mode, int16_t steps, rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw buffer_[2] = steps; @@ -153,7 +153,7 @@ void GroveMotorDriveTB6612FNG::stepper_keep_run(StepperModeTypeT mode, uint16_t uint16_t ms_per_step = 0; rpm = clamp(rpm, 1, 300); - ms_per_step = (uint16_t) (3000.0 / (float) rpm); + ms_per_step = (uint16_t) (3000.0f / (float) rpm); buffer_[0] = mode; buffer_[1] = cw; //(cw=1) => cw; (cw=0) => ccw diff --git a/esphome/components/grove_tb6612fng/grove_tb6612fng.h b/esphome/components/grove_tb6612fng/grove_tb6612fng.h index c021680519..a8648025b9 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.h +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.h @@ -47,7 +47,7 @@ enum StepperModeTypeT { MICRO_STEPPING = 3, }; -class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { +class GroveMotorDriveTB6612FNG final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -162,7 +162,7 @@ class GroveMotorDriveTB6612FNG : public Component, public i2c::I2CDevice { }; template -class GROVETB6612FNGMotorRunAction : public Action, public Parented { +class GROVETB6612FNGMotorRunAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) TEMPLATABLE_VALUE(uint16_t, speed) @@ -183,7 +183,7 @@ class GROVETB6612FNGMotorRunAction : public Action, public Parented -class GROVETB6612FNGMotorBrakeAction : public Action, public Parented { +class GROVETB6612FNGMotorBrakeAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -191,7 +191,7 @@ class GROVETB6612FNGMotorBrakeAction : public Action, public Parented -class GROVETB6612FNGMotorStopAction : public Action, public Parented { +class GROVETB6612FNGMotorStopAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, channel) @@ -199,19 +199,19 @@ class GROVETB6612FNGMotorStopAction : public Action, public Parented -class GROVETB6612FNGMotorStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->standby(); } }; template -class GROVETB6612FNGMotorNoStandbyAction : public Action, public Parented { +class GROVETB6612FNGMotorNoStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->not_standby(); } }; template -class GROVETB6612FNGMotorChangeAddressAction : public Action, public Parented { +class GROVETB6612FNGMotorChangeAddressAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, address) diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 27ae32cc46..18a7c917d5 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -65,7 +65,7 @@ constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 -class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { +class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void loop() override; void update() override; diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 7458b88b72..d1f0069341 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -25,6 +25,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -47,7 +48,7 @@ CODEOWNERS = ["@leeuwte"] growatt_solar_ns = cg.esphome_ns.namespace("growatt_solar") GrowattSolar = growatt_solar_ns.class_( - "GrowattSolar", cg.PollingComponent, modbus.ModbusDevice + "GrowattSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -162,10 +163,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("growatt_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) cg.add(var.set_protocol_version(config[CONF_PROTOCOL_VERSION])) diff --git a/esphome/components/gsl3670/__init__.py b/esphome/components/gsl3670/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /dev/null +++ b/esphome/components/gsl3670/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.cpp b/esphome/components/gsl3670/gsl3670_touchscreen.cpp new file mode 100644 index 0000000000..9115130f4a --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.cpp @@ -0,0 +1,167 @@ +#include "gsl3670_touchscreen.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::gsl3670 { + +static const char *const TAG = "gsl3670.touchscreen"; +static const size_t MAX_TOUCHES = 3; +// --------------------------------------------------------------------------- +// setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP: +// clear_reg → reset → load_fw → startup_chip → reset → startup_chip +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen..."); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } + + this->clear_reg_(); + this->reset_(); + this->load_firmware_(); + this->startup_chip_(); + this->reset_(); + this->startup_chip_(); + + ESP_LOGCONFIG(TAG, "GSL3670 initialised OK"); +} + +void GSL3670Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "GSL3670 Touchscreen:\n" + " X-raw-max: %d\n" + " Y-raw-max: %d\n", + this->x_raw_max_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_); +} + +// --------------------------------------------------------------------------- +// update_touches() – mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::update_touches() { + uint8_t buf[44] = {}; + auto err = this->read_register(0x80, buf, sizeof(buf)); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C read failed (%d)", err); + return; + } + uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES); + + // Build gsl_touch_info exactly as the Seeed driver does + for (uint8_t j = 0; j != finger_num; j++) { + // buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi + auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]); + auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]); + auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f; + ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y); + if (x <= 8192 && y <= 8192) + this->add_raw_touch_position_(id, x, y); + } +} + +// --------------------------------------------------------------------------- +// clear_reg_() – mirrors esp_lcd_touch_gsl3670_clear_reg() +// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::clear_reg_() { + ESP_LOGD(TAG, "clear_reg"); + + // GPIO reset pulse + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0x88, 0x01); + // delay(5); + this->write_reg8_(0xe4, 0x04); + // delay(5); + this->write_reg8_(0xe0, 0x00); + // delay(5); +} + +// --------------------------------------------------------------------------- +// reset_() – mirrors touch_gsl3670_reset() +// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::reset_() { + ESP_LOGD(TAG, "reset"); + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->digital_write(false); + delay(1); + this->reset_pin_->digital_write(true); + delay(5); + } + + this->write_reg8_(0xe4, 0x04); + + uint8_t zeros[4] = {0, 0, 0, 0}; + this->write_reg_(0xbc, zeros, 4); +} + +void GSL3670Touchscreen::load_firmware_() { + if (firmware_ == nullptr || firmware_len_ == 0) { + ESP_LOGW(TAG, "No firmware supplied – skipping"); + return; + } + + ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_); + + static constexpr size_t FW_BLK_SIZE = 128 + 4; + + for (size_t i = 0; i != this->firmware_len_; i++) { + auto offset = i * FW_BLK_SIZE; + uint8_t val = this->firmware_[offset + 0]; + ESP_LOGV(TAG, "Firmware address 0x%02X", val); + this->write_reg_(0xf0, &val, 1); + this->write_reg_(0, this->firmware_ + offset + 4, 128); + } + ESP_LOGD(TAG, "Firmware load complete"); +} + +// --------------------------------------------------------------------------- +// startup_chip_() – mirrors esp_lcd_touch_gsl3670_startup_chip() +// write 0x00 to 0xe0 +// --------------------------------------------------------------------------- +void GSL3670Touchscreen::startup_chip_() { + ESP_LOGD(TAG, "startup_chip"); + this->write_reg8_(0xe0, 0x00); + delay(5); +} + +// --------------------------------------------------------------------------- +// I2C helpers +// --------------------------------------------------------------------------- + +bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) { + auto err = this->write_register(reg, data, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err); + return false; + } + return true; +} + +bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); } + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.h b/esphome/components/gsl3670/gsl3670_touchscreen.h new file mode 100644 index 0000000000..3cce074f9b --- /dev/null +++ b/esphome/components/gsl3670/gsl3670_touchscreen.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::gsl3670 { + +// --------------------------------------------------------------------------- +// GSL3670 touchscreen ESPHome component +// --------------------------------------------------------------------------- +class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Supply the firmware table (generated by codegen from the YAML) + void set_firmware(const uint8_t *fw, size_t len) { + this->firmware_ = fw; + this->firmware_len_ = len; + } + + void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; } + + // touchscreen::Touchscreen / Component interface + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + + private: + // ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ---------- + void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence + void reset_(); // GPIO reset + 0xe4/0xbc sequence + void load_firmware_(); // write GSLX670_FW table + void startup_chip_(); // 0x00→0xe0 + gsl_DataInit + + // ---------- I2C helpers ---------- + bool write_reg_(uint8_t reg, const uint8_t *data, size_t len); + bool write_reg8_(uint8_t reg, uint8_t val); + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + + const uint8_t *firmware_{nullptr}; + size_t firmware_len_{0}; +}; + +} // namespace esphome::gsl3670 diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py new file mode 100644 index 0000000000..11bb24ce44 --- /dev/null +++ b/esphome/components/gsl3670/touchscreen.py @@ -0,0 +1,209 @@ +"""ESPHome codegen for the gsl3670 touchscreen sub-platform.""" + +import hashlib +import logging +from pathlib import Path + +from esphome import external_files, pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +from esphome.components.const import CONF_SHA256 +from esphome.components.touchscreen import ( + CONF_X_MAX, + CONF_X_MIN, + CONF_Y_MAX, + CONF_Y_MIN, + option_with_default, + touchscreen_schema, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_ID, + CONF_INTERRUPT_PIN, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODEL, + CONF_RESET_PIN, + CONF_SWAP_XY, + CONF_URL, +) +from esphome.core import ID + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["touchscreen"] +LOGGER = logging.getLogger(__name__) + +DOMAIN = "gsl3670" + +gsl3670_ns = cg.esphome_ns.namespace("gsl3670") +GSL3670Touchscreen = gsl3670_ns.class_( + "GSL3670Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONF_FIRMWARE = "firmware" + +# Firmware blobs are published as release assets of the companion repository +# rather than vendored into the ESPHome source tree. The default URL/SHA-256 +# for each model point at a pinned release artifact; users may override them +# (or supply a local file via `firmware: { file: ... }`). +FIRMWARE_RELEASE = "v1.0.0" +FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}" + +MODELS = { + "SEEED-RETERMINAL-D1001": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: True, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 872, + CONF_Y_MAX: 1644, + CONF_RESET_PIN: {"xl9535": None, "number": 14}, + CONF_INTERRUPT_PIN: 16, + CONF_FIRMWARE: { + CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin", + CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4", + }, + }, + "CUSTOM": {}, +} + +_FW_BLK_SIZE = 128 + 4 + + +def _validate_firmware_data(data: bytes, source: str) -> None: + """Validate the structure of a decoded GSL3670 firmware blob.""" + blk_cnt = len(data) // _FW_BLK_SIZE + if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data): + raise cv.Invalid(f"Firmware file length is incorrect: {source}") + for i in range(0, len(data), _FW_BLK_SIZE): + if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3: + raise cv.Invalid( + f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}" + ) + + +def _cache_path(url: str) -> Path: + """Cache path for a downloaded firmware blob, keyed by URL.""" + key = hashlib.sha256(url.encode()).hexdigest()[:8] + return external_files.compute_local_file_dir(DOMAIN) / key + + +def firmware_path(firmware: dict) -> Path: + """Return the path the firmware bytes will be read from at codegen time.""" + if path := firmware.get(CONF_FILE): + return path + return _cache_path(firmware[CONF_URL]) + + +def _validate_firmware(firmware: dict) -> dict: + """Require a single source, download (with caching), verify and validate.""" + if (CONF_FILE in firmware) == (CONF_URL in firmware): + raise cv.Invalid( + f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided" + ) + + if path := firmware.get(CONF_FILE): + _validate_firmware_data(path.read_bytes(), str(path.absolute())) + return firmware + + url = firmware[CONF_URL] + data = external_files.download_content(url, _cache_path(url)) + + if expected := firmware.get(CONF_SHA256): + actual = hashlib.sha256(data).hexdigest() + if actual.lower() != expected.lower(): + raise cv.Invalid( + f"Firmware SHA-256 mismatch for {url}: " + f"expected {expected.lower()}, got {actual}", + [CONF_SHA256], + ) + else: + LOGGER.warning( + "No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked" + ) + _validate_firmware_data(data, url) + return firmware + + +FIRMWARE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_URL): cv.url, + cv.Optional(CONF_SHA256): cv.string_strict, + cv.Optional(CONF_FILE): cv.file_, + } + ), + _validate_firmware, +) + + +def _config_schema(config): + model_option = { + cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) + } + config = cv.Schema(model_option, extra=True)(config) + defaults = MODELS[config[CONF_MODEL]] + schema = ( + touchscreen_schema(cv.UNDEFINED, False, defaults) + .extend( + { + cv.GenerateID(): cv.declare_id(GSL3670Touchscreen), + option_with_default( + CONF_INTERRUPT_PIN, defaults + ): pins.internal_gpio_input_pin_schema, + option_with_default( + CONF_RESET_PIN, defaults + ): pins.gpio_output_pin_schema, + **model_option, + option_with_default( + CONF_FIRMWARE, defaults, required=True + ): FIRMWARE_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x40)) + .extend(cv.COMPONENT_SCHEMA) + ) + return schema(config) + + +CONFIG_SCHEMA = _config_schema + + +def _read_firmware(config) -> bytes: + path = firmware_path(config[CONF_FIRMWARE]) + data = path.read_bytes() + LOGGER.info( + "Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks", + path.absolute(), + len(data), + len(data) // _FW_BLK_SIZE, + ) + return data + + +# --------------------------------------------------------------------------- +# Code generation +# --------------------------------------------------------------------------- +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if CONF_INTERRUPT_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + cg.add(var.set_interrupt_pin(pin)) + + if CONF_RESET_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + cg.add(var.set_reset_pin(pin)) + + # Firmware table + data = _read_firmware(config) + fw_array = cg.progmem_array( + ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data) + ) + cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE)) diff --git a/esphome/components/gt911/binary_sensor/gt911_button.h b/esphome/components/gt911/binary_sensor/gt911_button.h index 5aab457095..ccb725b50f 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.h +++ b/esphome/components/gt911/binary_sensor/gt911_button.h @@ -7,10 +7,10 @@ namespace esphome::gt911 { -class GT911Button : public binary_sensor::BinarySensor, - public Component, - public GT911ButtonListener, - public Parented { +class GT911Button final : public binary_sensor::BinarySensor, + public Component, + public GT911ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.h b/esphome/components/gt911/touchscreen/gt911_touchscreen.h index 0f1eeae720..465df528e5 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.h +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.h @@ -12,7 +12,7 @@ class GT911ButtonListener { virtual void update_button(uint8_t index, bool state) = 0; }; -class GT911Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { +class GT911Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: /// @brief Initialize the GT911 touchscreen. /// diff --git a/esphome/components/haier/automation.h b/esphome/components/haier/automation.h index e345867d6f..a81fd4bdb7 100644 --- a/esphome/components/haier/automation.h +++ b/esphome/components/haier/automation.h @@ -6,7 +6,7 @@ namespace esphome::haier { -template class DisplayOnAction : public Action { +template class DisplayOnAction final : public Action { public: DisplayOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(true); } @@ -15,7 +15,7 @@ template class DisplayOnAction : public Action { HaierClimateBase *parent_; }; -template class DisplayOffAction : public Action { +template class DisplayOffAction final : public Action { public: DisplayOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_display_state(false); } @@ -24,7 +24,7 @@ template class DisplayOffAction : public Action { HaierClimateBase *parent_; }; -template class BeeperOnAction : public Action { +template class BeeperOnAction final : public Action { public: BeeperOnAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(true); } @@ -33,7 +33,7 @@ template class BeeperOnAction : public Action { HonClimate *parent_; }; -template class BeeperOffAction : public Action { +template class BeeperOffAction final : public Action { public: BeeperOffAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_beeper_state(false); } @@ -42,7 +42,7 @@ template class BeeperOffAction : public Action { HonClimate *parent_; }; -template class VerticalAirflowAction : public Action { +template class VerticalAirflowAction final : public Action { public: VerticalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::VerticalSwingMode, direction) @@ -52,7 +52,7 @@ template class VerticalAirflowAction : public Action { HonClimate *parent_; }; -template class HorizontalAirflowAction : public Action { +template class HorizontalAirflowAction final : public Action { public: HorizontalAirflowAction(HonClimate *parent) : parent_(parent) {} TEMPLATABLE_VALUE(hon_protocol::HorizontalSwingMode, direction) @@ -62,7 +62,7 @@ template class HorizontalAirflowAction : public Action { HonClimate *parent_; }; -template class HealthOnAction : public Action { +template class HealthOnAction final : public Action { public: HealthOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(true); } @@ -71,7 +71,7 @@ template class HealthOnAction : public Action { HaierClimateBase *parent_; }; -template class HealthOffAction : public Action { +template class HealthOffAction final : public Action { public: HealthOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->set_health_mode(false); } @@ -80,7 +80,7 @@ template class HealthOffAction : public Action { HaierClimateBase *parent_; }; -template class StartSelfCleaningAction : public Action { +template class StartSelfCleaningAction final : public Action { public: StartSelfCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_self_cleaning(); } @@ -89,7 +89,7 @@ template class StartSelfCleaningAction : public Action { HonClimate *parent_; }; -template class StartSteriCleaningAction : public Action { +template class StartSteriCleaningAction final : public Action { public: StartSteriCleaningAction(HonClimate *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->start_steri_cleaning(); } @@ -98,7 +98,7 @@ template class StartSteriCleaningAction : public Action { HonClimate *parent_; }; -template class PowerOnAction : public Action { +template class PowerOnAction final : public Action { public: PowerOnAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_on_command(); } @@ -107,7 +107,7 @@ template class PowerOnAction : public Action { HaierClimateBase *parent_; }; -template class PowerOffAction : public Action { +template class PowerOffAction final : public Action { public: PowerOffAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->send_power_off_command(); } @@ -116,7 +116,7 @@ template class PowerOffAction : public Action { HaierClimateBase *parent_; }; -template class PowerToggleAction : public Action { +template class PowerToggleAction final : public Action { public: PowerToggleAction(HaierClimateBase *parent) : parent_(parent) {} void play(const Ts &...x) { this->parent_->toggle_power(); } diff --git a/esphome/components/haier/button/self_cleaning.h b/esphome/components/haier/button/self_cleaning.h index 9d330e4dfe..fc5a73b1e8 100644 --- a/esphome/components/haier/button/self_cleaning.h +++ b/esphome/components/haier/button/self_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SelfCleaningButton : public button::Button, public Parented { +class SelfCleaningButton final : public button::Button, public Parented { public: SelfCleaningButton() = default; diff --git a/esphome/components/haier/button/steri_cleaning.h b/esphome/components/haier/button/steri_cleaning.h index cac02dd267..4799c0e2ae 100644 --- a/esphome/components/haier/button/steri_cleaning.h +++ b/esphome/components/haier/button/steri_cleaning.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class SteriCleaningButton : public button::Button, public Parented { +class SteriCleaningButton final : public button::Button, public Parented { public: SteriCleaningButton() = default; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ad9b00ce4..f68404afd9 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -607,7 +607,7 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 5b477a5cea..ba36e6a8fb 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -35,7 +35,7 @@ struct HonSettings { bool quiet_mode_state{false}; }; -class HonClimate : public HaierClimateBase { +class HonClimate final : public HaierClimateBase { #ifdef USE_SENSOR public: enum class SubSensorType { diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index bd5678a425..a013371649 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -341,7 +341,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 - out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; + out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49f) ? 1 : 0; } if (out_data->ac_power == 0) { // If AC is off - no presets allowed diff --git a/esphome/components/haier/smartair2_climate.h b/esphome/components/haier/smartair2_climate.h index 68b0e4a0db..dc9a60f06f 100644 --- a/esphome/components/haier/smartair2_climate.h +++ b/esphome/components/haier/smartair2_climate.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class Smartair2Climate : public HaierClimateBase { +class Smartair2Climate final : public HaierClimateBase { public: Smartair2Climate(); Smartair2Climate(const Smartair2Climate &) = delete; diff --git a/esphome/components/haier/switch/beeper.h b/esphome/components/haier/switch/beeper.h index 2d20f1cd83..f27b419a2e 100644 --- a/esphome/components/haier/switch/beeper.h +++ b/esphome/components/haier/switch/beeper.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class BeeperSwitch : public switch_::Switch, public Parented { +class BeeperSwitch final : public switch_::Switch, public Parented { public: BeeperSwitch() = default; diff --git a/esphome/components/haier/switch/display.h b/esphome/components/haier/switch/display.h index 9baf3b9fb8..bf60538e11 100644 --- a/esphome/components/haier/switch/display.h +++ b/esphome/components/haier/switch/display.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class DisplaySwitch : public switch_::Switch, public Parented { +class DisplaySwitch final : public switch_::Switch, public Parented { public: DisplaySwitch() = default; diff --git a/esphome/components/haier/switch/health_mode.h b/esphome/components/haier/switch/health_mode.h index ec77b1638a..f5d3dad0f2 100644 --- a/esphome/components/haier/switch/health_mode.h +++ b/esphome/components/haier/switch/health_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class HealthModeSwitch : public switch_::Switch, public Parented { +class HealthModeSwitch final : public switch_::Switch, public Parented { public: HealthModeSwitch() = default; diff --git a/esphome/components/haier/switch/quiet_mode.h b/esphome/components/haier/switch/quiet_mode.h index 8ef7b5bb89..f1ab85f4e3 100644 --- a/esphome/components/haier/switch/quiet_mode.h +++ b/esphome/components/haier/switch/quiet_mode.h @@ -5,7 +5,7 @@ namespace esphome::haier { -class QuietModeSwitch : public switch_::Switch, public Parented { +class QuietModeSwitch final : public switch_::Switch, public Parented { public: QuietModeSwitch() = default; diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index c54b0dcf14..02e999c56c 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -8,7 +8,7 @@ namespace esphome::havells_solar { -class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { +class HavellsSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index f0683e1d9c..d18ae0d9af 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -28,6 +28,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType CONF_ENERGY_PRODUCTION_DAY = "energy_production_day" CONF_TOTAL_ENERGY_PRODUCTION = "total_energy_production" @@ -58,7 +59,7 @@ CODEOWNERS = ["@sourabhjaiswal"] havells_solar_ns = cg.esphome_ns.namespace("havells_solar") HavellsSolar = havells_solar_ns.class_( - "HavellsSolar", cg.PollingComponent, modbus.ModbusDevice + "HavellsSolar", cg.PollingComponent, modbus.ModbusClientDevice ) PHASE_SENSORS = { @@ -216,10 +217,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("havells_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_FREQUENCY in config: sens = await sensor.new_sensor(config[CONF_FREQUENCY]) diff --git a/esphome/components/hbridge/fan/hbridge_fan.h b/esphome/components/hbridge/fan/hbridge_fan.h index 62149d99cd..187b6d2a97 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.h +++ b/esphome/components/hbridge/fan/hbridge_fan.h @@ -12,7 +12,7 @@ enum DecayMode { DECAY_MODE_FAST = 1, }; -class HBridgeFan : public Component, public fan::Fan { +class HBridgeFan final : public Component, public fan::Fan { public: HBridgeFan(int speed_count, DecayMode decay_mode) : speed_count_(speed_count), decay_mode_(decay_mode) {} @@ -46,7 +46,7 @@ class HBridgeFan : public Component, public fan::Fan { void set_hbridge_levels_(float a_level, float b_level, float enable); }; -template class BrakeAction : public Action { +template class BrakeAction final : public Action { public: explicit BrakeAction(HBridgeFan *parent) : parent_(parent) {} diff --git a/esphome/components/hbridge/switch/hbridge_switch.h b/esphome/components/hbridge/switch/hbridge_switch.h index de867271fe..5c03958991 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.h +++ b/esphome/components/hbridge/switch/hbridge_switch.h @@ -16,7 +16,7 @@ enum RelayState : uint8_t { RELAY_STATE_UNKNOWN = 4, }; -class HBridgeSwitch : public switch_::Switch, public Component { +class HBridgeSwitch final : public switch_::Switch, public Component { public: void set_on_pin(GPIOPin *pin) { this->on_pin_ = pin; } void set_off_pin(GPIOPin *pin) { this->off_pin_ = pin; } diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h index b060f38a80..681dffe4f6 100644 --- a/esphome/components/hc8/hc8.h +++ b/esphome/components/hc8/hc8.h @@ -9,7 +9,7 @@ namespace esphome::hc8 { -class HC8Component : public PollingComponent, public uart::UARTDevice { +class HC8Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -26,7 +26,7 @@ class HC8Component : public PollingComponent, public uart::UARTDevice { bool warmup_complete_{false}; }; -template class HC8CalibrateAction : public Action, public Parented { +template class HC8CalibrateAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, baseline) diff --git a/esphome/components/hdc1080/hdc1080.h b/esphome/components/hdc1080/hdc1080.h index 1e3bf77788..21580ff9ab 100644 --- a/esphome/components/hdc1080/hdc1080.h +++ b/esphome/components/hdc1080/hdc1080.h @@ -6,7 +6,7 @@ namespace esphome::hdc1080 { -class HDC1080Component : public PollingComponent, public i2c::I2CDevice { +class HDC1080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/hdc2010/hdc2010.h b/esphome/components/hdc2010/hdc2010.h index ad6df3ff48..95c8c24e60 100644 --- a/esphome/components/hdc2010/hdc2010.h +++ b/esphome/components/hdc2010/hdc2010.h @@ -6,7 +6,7 @@ namespace esphome::hdc2010 { -class HDC2010Component : public PollingComponent, public i2c::I2CDevice { +class HDC2010Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } diff --git a/esphome/components/hdc2080/hdc2080.h b/esphome/components/hdc2080/hdc2080.h index daa10d371d..8d86a7d41c 100644 --- a/esphome/components/hdc2080/hdc2080.h +++ b/esphome/components/hdc2080/hdc2080.h @@ -6,7 +6,7 @@ namespace esphome::hdc2080 { -class HDC2080Component : public PollingComponent, public i2c::I2CDevice { +class HDC2080Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } void set_humidity(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } diff --git a/esphome/components/hdc302x/hdc302x.h b/esphome/components/hdc302x/hdc302x.h index 6afea0a8c0..cc5343ee89 100644 --- a/esphome/components/hdc302x/hdc302x.h +++ b/esphome/components/hdc302x/hdc302x.h @@ -20,7 +20,7 @@ enum HDC302XPowerMode : uint8_t { Datasheet: https://www.ti.com/lit/ds/symlink/hdc3020.pdf */ -class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { +class HDC302XComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; @@ -48,7 +48,7 @@ class HDC302XComponent : public PollingComponent, public i2c::I2CDevice { uint32_t conversion_delay_ms_(); }; -template class HeaterOnAction : public Action, public Parented { +template class HeaterOnAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint16_t, power) TEMPLATABLE_VALUE(uint32_t, duration) @@ -60,7 +60,7 @@ template class HeaterOnAction : public Action, public Par } }; -template class HeaterOffAction : public Action, public Parented { +template class HeaterOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_heater(); } }; diff --git a/esphome/components/he60r/he60r.h b/esphome/components/he60r/he60r.h index e7b5c97969..ef8dde4804 100644 --- a/esphome/components/he60r/he60r.h +++ b/esphome/components/he60r/he60r.h @@ -7,7 +7,7 @@ namespace esphome::he60r { -class HE60rCover : public cover::Cover, public Component, public uart::UARTDevice { +class HE60rCover final : public cover::Cover, public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index a277424df6..8e0668d59d 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -93,7 +93,7 @@ enum VerticalDirection { const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius -class HeatpumpIRClimate : public climate_ir::ClimateIR { +class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: HeatpumpIRClimate() : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.h b/esphome/components/hitachi_ac344/hitachi_ac344.h index b9d776cc59..c5773ac222 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.h +++ b/esphome/components/hitachi_ac344/hitachi_ac344.h @@ -75,7 +75,7 @@ const uint16_t HITACHI_AC344_BITS = HITACHI_AC344_STATE_LENGTH * 8; #define GETBIT8(a, b) ((a) & ((uint8_t) 1 << (b))) #define GETBITS8(data, offset, size) (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC344_TEMP_MIN, HITACHI_AC344_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.h b/esphome/components/hitachi_ac424/hitachi_ac424.h index ef7f128a5a..31efd98c3d 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.h +++ b/esphome/components/hitachi_ac424/hitachi_ac424.h @@ -77,7 +77,7 @@ const uint16_t HITACHI_AC424_BITS = HITACHI_AC424_STATE_LENGTH * 8; #define HITACHI_AC424_GETBITS8(data, offset, size) \ (((data) & (((uint8_t) UINT8_MAX >> (8 - (size))) << (offset))) >> (offset)) -class HitachiClimate : public climate_ir::ClimateIR { +class HitachiClimate final : public climate_ir::ClimateIR { public: HitachiClimate() : climate_ir::ClimateIR(HITACHI_AC424_TEMP_MIN, HITACHI_AC424_TEMP_MAX, 1.0F, true, true, diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index fd8257b435..34246f52f0 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -71,7 +71,7 @@ enum HlkFm22xFaceDirection { FACE_DIRECTION_UP = 0x10, }; -class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { +class HlkFm22xComponent final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -141,7 +141,7 @@ class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { CallbackManager enrollment_failed_callback_; }; -template class EnrollmentAction : public Action, public Parented { +template class EnrollmentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(std::string, name) TEMPLATABLE_VALUE(uint8_t, direction) @@ -153,7 +153,7 @@ template class EnrollmentAction : public Action, public P } }; -template class DeleteAction : public Action, public Parented { +template class DeleteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(int16_t, face_id) @@ -163,17 +163,17 @@ template class DeleteAction : public Action, public Paren } }; -template class DeleteAllAction : public Action, public Parented { +template class DeleteAllAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->delete_all_faces(); } }; -template class ScanAction : public Action, public Parented { +template class ScanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->scan_face(); } }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; diff --git a/esphome/components/hlw8012/hlw8012.h b/esphome/components/hlw8012/hlw8012.h index d1d340bf45..0691132498 100644 --- a/esphome/components/hlw8012/hlw8012.h +++ b/esphome/components/hlw8012/hlw8012.h @@ -23,7 +23,7 @@ enum HLW8012SensorModels { #define USE_PCNT false #endif -class HLW8012Component : public PollingComponent { +class HLW8012Component final : public PollingComponent { public: HLW8012Component() : cf_store_(*pulse_counter::get_storage(USE_PCNT)), cf1_store_(*pulse_counter::get_storage(USE_PCNT)) {} diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h index d4c7dbd26c..56fd27a15a 100644 --- a/esphome/components/hlw8032/hlw8032.h +++ b/esphome/components/hlw8032/hlw8032.h @@ -6,7 +6,7 @@ namespace esphome::hlw8032 { -class HLW8032Component : public Component, public uart::UARTDevice { +class HLW8032Component final : public Component, public uart::UARTDevice { public: void loop() override; void dump_config() override; diff --git a/esphome/components/hm3301/hm3301.h b/esphome/components/hm3301/hm3301.h index 55e708e34a..adbd8450ed 100644 --- a/esphome/components/hm3301/hm3301.h +++ b/esphome/components/hm3301/hm3301.h @@ -9,7 +9,7 @@ namespace esphome::hm3301 { static const uint8_t SELECT_COMM_CMD = 0x88; -class HM3301Component : public PollingComponent, public i2c::I2CDevice { +class HM3301Component final : public PollingComponent, public i2c::I2CDevice { public: HM3301Component() = default; diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp index c113cb48a6..d8e1f059a6 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.cpp +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -1,6 +1,6 @@ #include #include "hmac_sha256.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" namespace esphome::hmac_sha256 { diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h index 22129b1182..74ac4c23de 100644 --- a/esphome/components/hmac_sha256/hmac_sha256.h +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -1,7 +1,7 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index 7930df7a38..c6b7da6610 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" +#include + namespace esphome::hmc5883l { static const char *const TAG = "hmc5883l"; @@ -126,7 +128,7 @@ void HMC5883LComponent::update() { const float y = int16_t(raw_y) * mg_per_bit * 0.1f; const float z = int16_t(raw_z) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/hmc5883l/hmc5883l.h b/esphome/components/hmc5883l/hmc5883l.h index 4f170d7401..d23eb1a0f4 100644 --- a/esphome/components/hmc5883l/hmc5883l.h +++ b/esphome/components/hmc5883l/hmc5883l.h @@ -34,7 +34,7 @@ enum HMC5883LRange { HMC5883L_RANGE_810_UT = 0b111, }; -class HMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class HMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 6d95ea2c60..c713b143af 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { +class HomeassistantBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index a1e351fdf4..c9673234ee 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -6,7 +6,7 @@ namespace esphome::homeassistant { -class HomeassistantNumber : public number::Number, public Component { +class HomeassistantNumber final : public number::Number, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index afc4935537..e787039ee3 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSensor : public sensor::Sensor, public Component { +class HomeassistantSensor final : public sensor::Sensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index c6c178c205..3dd1ab1525 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantSwitch : public switch_::Switch, public Component { +class HomeassistantSwitch final : public switch_::Switch, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index 8af81cefcb..63ec136b57 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { +class HomeassistantTextSensor final : public text_sensor::TextSensor, public Component { public: void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void set_attribute(const char *attribute) { this->attribute_ = attribute; } diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.h b/esphome/components/honeywell_hih_i2c/honeywell_hih.h index d9ea6401ce..6d02044cfc 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.h +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.h @@ -7,7 +7,7 @@ namespace esphome::honeywell_hih_i2c { -class HoneywellHIComponent : public PollingComponent, public i2c::I2CDevice { +class HoneywellHIComponent final : public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index 8bfc5e4f4f..dd86b95c78 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -55,7 +55,9 @@ float HONEYWELLABPSensor::countstopressure_(const int counts, const float min_pr // Converts a digital temperature measurement in counts to temperature in C // This will be invalid if sensore daoes not have temperature measurement capability -float HONEYWELLABPSensor::countstotemperatures_(const int counts) { return (((float) counts / 2047.0) * 200.0) - 50.0; } +float HONEYWELLABPSensor::countstotemperatures_(const int counts) { + return (((float) counts / 2047.0f) * 200.0f) - 50.0f; +} // Pressure value from the most recent reading in units float HONEYWELLABPSensor::read_pressure_() { @@ -69,9 +71,9 @@ void HONEYWELLABPSensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP Sensor"); if (readsensor_() == 0) { if (this->pressure_sensor_ != nullptr) - this->pressure_sensor_->publish_state(read_pressure_() * 1.0); + this->pressure_sensor_->publish_state(read_pressure_() * 1.0f); if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(read_temperature_() * 1.0); + this->temperature_sensor_->publish_state(read_temperature_() * 1.0f); } } diff --git a/esphome/components/honeywellabp/honeywellabp.h b/esphome/components/honeywellabp/honeywellabp.h index 3c31968c49..067311b8d4 100644 --- a/esphome/components/honeywellabp/honeywellabp.h +++ b/esphome/components/honeywellabp/honeywellabp.h @@ -8,9 +8,9 @@ namespace esphome::honeywellabp { -class HONEYWELLABPSensor : public PollingComponent, - public spi::SPIDevice { +class HONEYWELLABPSensor final : public PollingComponent, + public spi::SPIDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.h b/esphome/components/honeywellabp2_i2c/honeywellabp2.h index 41ea21344b..70f435f8b0 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.h +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.h @@ -11,7 +11,7 @@ namespace esphome::honeywellabp2_i2c { enum ABP2TRANFERFUNCTION { ABP2_TRANS_FUNC_A = 0, ABP2_TRANS_FUNC_B = 1 }; -class HONEYWELLABP2Sensor : public PollingComponent, public i2c::I2CDevice { +class HONEYWELLABP2Sensor final : public PollingComponent, public i2c::I2CDevice { public: void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; }; void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }; diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index 6f2bccf102..bd2d09257b 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -6,7 +6,7 @@ namespace esphome::host { -class HostGPIOPin : public InternalGPIOPin { +class HostGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/host/preference_backend.h b/esphome/components/host/preference_backend.h index 68537cad28..ab1443ed49 100644 --- a/esphome/components/host/preference_backend.h +++ b/esphome/components/host/preference_backend.h @@ -10,8 +10,8 @@ class HostPreferenceBackend final { public: explicit HostPreferenceBackend(uint32_t key) : key_(key) {} - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); + bool save(const uint8_t *data, size_t len) const; + bool load(uint8_t *data, size_t len) const; protected: uint32_t key_{}; diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index c0be270062..497b9d11e5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -14,21 +14,31 @@ static const char *const TAG = "preferences"; void HostPreferences::setup_() { if (this->setup_complete_) return; - const char *home = getenv("HOME"); - if (home == nullptr) { - ESP_LOGE(TAG, "HOME environment variable is not set"); - abort(); + const char *prefdir = getenv("ESPHOME_PREFDIR"); + std::string pref_path; + if (prefdir != nullptr) { + pref_path = prefdir; + } else { + const char *home = getenv("HOME"); + if (home == nullptr) { + ESP_LOGE(TAG, "ESPHOME_PREFDIR and HOME environment variables not set, unable to save preferences"); + return; + } + pref_path = std::string(home) + "/.esphome/prefs"; } - this->filename_.append(home); - this->filename_.append("/.esphome"); - this->filename_.append("/prefs"); - fs::create_directories(this->filename_); + std::error_code ec; + fs::create_directories(pref_path, ec); + if (ec) { + ESP_LOGE(TAG, "Failed to create preferences directory: %s (%s)", pref_path.c_str(), ec.message().c_str()); + return; + } + this->filename_ = pref_path; this->filename_.append("/"); this->filename_.append(App.get_name()); this->filename_.append(".prefs"); FILE *fp = fopen(this->filename_.c_str(), "rb"); if (fp != nullptr) { - while (!feof((fp))) { + while (!feof(fp)) { uint32_t key; uint8_t len; if (fread(&key, sizeof(key), 1, fp) != 1) @@ -39,7 +49,7 @@ void HostPreferences::setup_() { if (fread(data, sizeof(uint8_t), len, fp) != len) break; std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; } fclose(fp); } @@ -48,29 +58,33 @@ void HostPreferences::setup_() { bool HostPreferences::sync() { this->setup_(); + if (this->filename_.empty()) { + ESP_LOGE(TAG, "Preferences filename not set, unable to save preferences"); + return false; + } FILE *fp = fopen(this->filename_.c_str(), "wb"); if (fp == nullptr) { ESP_LOGE(TAG, "Failed to open preferences file for writing: %s", this->filename_.c_str()); return false; } - for (auto it = this->data.begin(); it != this->data.end(); ++it) { - fwrite(&it->first, sizeof(uint32_t), 1, fp); - uint8_t len = it->second.size(); + for (auto &it : this->data_) { + fwrite(&it.first, sizeof(uint32_t), 1, fp); + uint8_t len = it.second.size(); fwrite(&len, sizeof(len), 1, fp); - fwrite(it->second.data(), sizeof(uint8_t), it->second.size(), fp); + fwrite(it.second.data(), sizeof(uint8_t), it.second.size(), fp); } fclose(fp); return true; } bool HostPreferences::reset() { - host_preferences->data.clear(); + host_preferences->data_.clear(); return true; } ESPPreferenceObject HostPreferences::make_preference(size_t length, uint32_t type, bool in_flash) { - auto backend = new HostPreferenceBackend(type); + auto *backend = new HostPreferenceBackend(type); return ESPPreferenceObject(backend); }; @@ -83,11 +97,13 @@ void setup_preferences() { global_preferences = &s_preferences; } -bool HostPreferenceBackend::save(const uint8_t *data, size_t len) { +bool HostPreferenceBackend::save(const uint8_t *data, size_t len) const { return host_preferences->save(this->key_, data, len); } -bool HostPreferenceBackend::load(uint8_t *data, size_t len) { return host_preferences->load(this->key_, data, len); } +bool HostPreferenceBackend::load(uint8_t *data, size_t len) const { + return host_preferences->load(this->key_, data, len); +} HostPreferences *host_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/host/preferences.h b/esphome/components/host/preferences.h index 25858799ff..5f723e0675 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -23,7 +23,7 @@ class HostPreferences final : public PreferencesMixin { return false; this->setup_(); std::vector vec(data, data + len); - this->data[key] = vec; + this->data_[key] = vec; return true; } @@ -31,8 +31,8 @@ class HostPreferences final : public PreferencesMixin { if (len > 255) return false; this->setup_(); - auto it = this->data.find(key); - if (it == this->data.end()) + auto it = this->data_.find(key); + if (it == this->data_.end()) return false; const auto &vec = it->second; if (vec.size() != len) @@ -45,7 +45,7 @@ class HostPreferences final : public PreferencesMixin { void setup_(); bool setup_complete_{}; std::string filename_{}; - std::map> data{}; + std::map> data_{}; }; void setup_preferences(); diff --git a/esphome/components/host/time/host_time.h b/esphome/components/host/time/host_time.h index 19e1af99d1..4462108b6d 100644 --- a/esphome/components/host/time/host_time.h +++ b/esphome/components/host/time/host_time.h @@ -5,7 +5,7 @@ namespace esphome::host { -class HostTime : public time::RealTimeClock { +class HostTime final : public time::RealTimeClock { public: void update() override {} }; diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 0b3a746c34..270bb2709d 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -55,7 +55,7 @@ void HrxlMaxsonarWrComponent::check_buffer_() { millimeters = millimeters * 10; } - float meters = float(millimeters) / 1000.0; + float meters = float(millimeters) / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %d mm, %f m", millimeters, meters); this->publish_state(meters); } else { diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h index e98eeea723..36346c8293 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.h @@ -6,7 +6,7 @@ namespace esphome::hrxl_maxsonar_wr { -class HrxlMaxsonarWrComponent : public sensor::Sensor, public Component, public uart::UARTDevice { +class HrxlMaxsonarWrComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { public: // Nothing really public. diff --git a/esphome/components/hte501/hte501.h b/esphome/components/hte501/hte501.h index 310073f88b..403d3c1de6 100644 --- a/esphome/components/hte501/hte501.h +++ b/esphome/components/hte501/hte501.h @@ -7,7 +7,7 @@ namespace esphome::hte501 { /// This class implements support for the hte501 of temperature i2c sensors. -class HTE501Component : public PollingComponent, public i2c::I2CDevice { +class HTE501Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index fd033dac7f..54d7f5c77b 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -73,7 +73,7 @@ def validate_url(value): def validate_ssl_verification(config): error_message = "" - if CORE.is_rp2040 and config[CONF_VERIFY_SSL]: + if CORE.is_rp2 and config[CONF_VERIFY_SSL]: error_message = "ESPHome does not support certificate verification on RP2040" if ( @@ -96,7 +96,7 @@ def _declare_request_class(value): return cv.declare_id(HttpRequestHost)(value) if CORE.is_esp32: return cv.declare_id(HttpRequestIDF)(value) - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return cv.declare_id(HttpRequestArduino)(value) return NotImplementedError @@ -118,7 +118,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, cv.Optional(CONF_WATCHDOG_TIMEOUT): cv.All( - cv.Any(cv.only_on_esp32, cv.only_on_rp2040), + cv.Any(cv.only_on_esp32, cv.only_on_rp2), cv.positive_not_null_time_period, cv.positive_time_period_milliseconds, ), @@ -144,7 +144,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), host=cv.Version(0, 0, 0), ), validate_ssl_verification, @@ -204,7 +204,7 @@ async def to_code(config): ) if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("HTTPClient", None) if CORE.is_host: if IS_MACOS: @@ -368,7 +368,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "http_request_host.cpp": {PlatformFramework.HOST_NATIVE}, "http_request_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 2477e26bc1..df1bb462ab 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -311,7 +311,7 @@ inline HttpReadResult http_read_fully(HttpContainer *container, uint8_t *buffer, return {HttpReadStatus::OK, 0}; } -class HttpRequestResponseTrigger : public Trigger, std::string &> { +class HttpRequestResponseTrigger final : public Trigger, std::string &> { public: void process(const std::shared_ptr &container, std::string &response_body) { this->trigger(container, response_body); @@ -447,7 +447,7 @@ class HttpRequestComponent : public Component { uint32_t watchdog_timeout_{0}; }; -template class HttpRequestSendAction : public Action { +template class HttpRequestSendAction final : public Action { public: HttpRequestSendAction(HttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) @@ -510,9 +510,9 @@ template class HttpRequestSendAction : public Action { return; } - size_t max_length = this->max_response_buffer_size_; #ifdef USE_HTTP_REQUEST_RESPONSE if (this->capture_response_.value(x...)) { + size_t max_length = this->max_response_buffer_size_; std::string response_body; RAMAllocator allocator; uint8_t *buf = allocator.allocate(max_length); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index bb5e9427dd..1760cb9395 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -72,7 +72,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur bool status = container->client_.begin(*stream_ptr, url.c_str()); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index b009d45b1c..c109de8a39 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -4,7 +4,7 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) -#if defined(USE_RP2040) +#if defined(USE_RP2) #include #include #endif @@ -46,7 +46,7 @@ class HttpContainerArduino : public HttpContainer { size_t chunk_remaining_{0}; ///< Bytes remaining in current chunk }; -class HttpRequestArduino : public HttpRequestComponent { +class HttpRequestArduino final : public HttpRequestComponent { public: #ifdef USE_ESP8266 void set_tls_buffer_size_rx(uint16_t size) { this->tls_buffer_size_rx_ = size; } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 52be0e8a16..9045702f46 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -16,7 +16,7 @@ class HttpContainerHost : public HttpContainer { std::vector response_body_{}; }; -class HttpRequestHost : public HttpRequestComponent { +class HttpRequestHost final : public HttpRequestComponent { public: std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, const std::vector
&request_headers, diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 9ed1a97b1a..8a803b5469 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -26,7 +26,7 @@ class HttpContainerIDF : public HttpContainer { esp_http_client_handle_t client_; }; -class HttpRequestIDF : public HttpRequestComponent { +class HttpRequestIDF final : public HttpRequestComponent { public: void dump_config() override; diff --git a/esphome/components/http_request/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index 1bb54599dc..b7026e0f55 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -36,7 +36,7 @@ CONFIG_SCHEMA = cv.All( esp8266_arduino=cv.Version(2, 5, 1), esp32_arduino=cv.Version(0, 0, 0), esp_idf=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), ), ) diff --git a/esphome/components/http_request/ota/automation.h b/esphome/components/http_request/ota/automation.h index f6f49b14b1..487f6b70a1 100644 --- a/esphome/components/http_request/ota/automation.h +++ b/esphome/components/http_request/ota/automation.h @@ -5,7 +5,7 @@ namespace esphome::http_request { -template class OtaHttpRequestComponentFlashAction : public Action { +template class OtaHttpRequestComponentFlashAction final : public Action { public: OtaHttpRequestComponentFlashAction(OtaHttpRequestComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, md5_url) diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index a111722dc7..f86d62c5e8 100644 --- a/esphome/components/htu21d/htu21d.h +++ b/esphome/components/htu21d/htu21d.h @@ -9,7 +9,7 @@ namespace esphome::htu21d { enum HTU21DSensorModels { HTU21D_SENSOR_MODEL_HTU21D = 0, HTU21D_SENSOR_MODEL_SI7021, HTU21D_SENSOR_MODEL_SHT21 }; -class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU21DComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -34,7 +34,7 @@ class HTU21DComponent : public PollingComponent, public i2c::I2CDevice { HTU21DSensorModels sensor_model_{HTU21D_SENSOR_MODEL_HTU21D}; }; -template class SetHeaterLevelAction : public Action, public Parented { +template class SetHeaterLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -45,7 +45,7 @@ template class SetHeaterLevelAction : public Action, publ } }; -template class SetHeaterAction : public Action, public Parented { +template class SetHeaterAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, status) diff --git a/esphome/components/htu31d/htu31d.h b/esphome/components/htu31d/htu31d.h index 451918cb3b..c25a979600 100644 --- a/esphome/components/htu31d/htu31d.h +++ b/esphome/components/htu31d/htu31d.h @@ -7,7 +7,7 @@ namespace esphome::htu31d { -class HTU31DComponent : public PollingComponent, public i2c::I2CDevice { +class HTU31DComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; /// Setup (reset) the sensor and check connection. void update() override; /// Update the sensor values (temperature+humidity). diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 0d1b87941d..a404fbbade 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -1,4 +1,3 @@ -import logging from typing import Any from esphome import automation, pins @@ -27,8 +26,6 @@ from esphome.types import ConfigType from . import boards, hub75_ns -_LOGGER = logging.getLogger(__name__) - DEPENDENCIES = ["esp32"] CODEOWNERS = ["@stuartparmenter"] @@ -133,30 +130,11 @@ SCAN_WIRINGS = { "SCAN_1_8_64PX_HIGH": Hub75ScanWiring.SCAN_1_8_64PX_HIGH, } -# Deprecated scan wiring names - mapped to new names -DEPRECATED_SCAN_WIRINGS = { - "FOUR_SCAN_16PX_HIGH": "SCAN_1_4_16PX_HIGH", - "FOUR_SCAN_32PX_HIGH": "SCAN_1_8_32PX_HIGH", - "FOUR_SCAN_64PX_HIGH": "SCAN_1_8_64PX_HIGH", -} - def _validate_scan_wiring(value): - """Validate scan_wiring with deprecation warnings for old names.""" + """Validate scan_wiring against the allowed names.""" value = cv.string(value).upper().replace(" ", "_") - # Check if using deprecated name - # Remove deprecated names in 2026.7.0 - if value in DEPRECATED_SCAN_WIRINGS: - new_name = DEPRECATED_SCAN_WIRINGS[value] - _LOGGER.warning( - "Scan wiring '%s' is deprecated and will be removed in ESPHome 2026.7.0. " - "Please use '%s' instead.", - value, - new_name, - ) - value = new_name - # Validate against allowed values if value not in SCAN_WIRINGS: raise cv.Invalid( diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index ab7e3fc5b1..98bc2e52e6 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -16,7 +16,7 @@ namespace esphome::hub75 { using esphome::display::ColorBitness; using esphome::display::ColorOrder; -class HUB75Display : public display::Display { +class HUB75Display final : public display::Display { public: // Constructor accepting config explicit HUB75Display(const Hub75Config &config); @@ -51,7 +51,7 @@ class HUB75Display : public display::Display { bool enabled_{false}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) diff --git a/esphome/components/hx711/hx711.h b/esphome/components/hx711/hx711.h index 43ab4c0f56..62d0171d8f 100644 --- a/esphome/components/hx711/hx711.h +++ b/esphome/components/hx711/hx711.h @@ -14,7 +14,7 @@ enum HX711Gain : uint8_t { HX711_GAIN_64 = 3, }; -class HX711Sensor : public sensor::Sensor, public PollingComponent { +class HX711Sensor final : public sensor::Sensor, public PollingComponent { public: void set_dout_pin(GPIOPin *dout_pin) { dout_pin_ = dout_pin; } void set_sck_pin(GPIOPin *sck_pin) { sck_pin_ = sck_pin; } diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.h b/esphome/components/hydreon_rgxx/hydreon_rgxx.h index 2ae46907c1..a7bde6105c 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.h +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.h @@ -32,7 +32,7 @@ static const uint8_t NUM_SENSORS = 1; #define HYDREON_RGXX_IGNORE_LIST(F, SEP) F("Emitters") SEP F("Event") SEP F("Reset") -class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { +class HydreonRGxxComponent final : public PollingComponent, public uart::UARTDevice { public: void set_sensor(sensor::Sensor *sensor, int index) { this->sensors_[index] = sensor; } #ifdef USE_BINARY_SENSOR @@ -86,7 +86,7 @@ class HydreonRGxxComponent : public PollingComponent, public uart::UARTDevice { int sensors_received_ = -1; }; -class HydreonRGxxBinaryComponent : public Component { +class HydreonRGxxBinaryComponent final : public Component { public: HydreonRGxxBinaryComponent(HydreonRGxxComponent *parent) {} }; diff --git a/esphome/components/hyt271/hyt271.h b/esphome/components/hyt271/hyt271.h index d08b3779ad..b373c26466 100644 --- a/esphome/components/hyt271/hyt271.h +++ b/esphome/components/hyt271/hyt271.h @@ -6,7 +6,7 @@ namespace esphome::hyt271 { -class HYT271Component : public PollingComponent, public i2c::I2CDevice { +class HYT271Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index d9dd6d5ee2..7b163d065e 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -13,14 +13,18 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, get_esp32_variant, ) from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_s31 import esp32_s31_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -45,7 +49,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -72,14 +76,18 @@ ESP32_I2C_CAPABILITIES = { VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H4: {"NUM": 2, "HP": 2}, + VARIANT_ESP32H21: {"NUM": 2, "HP": 2}, VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S31: {"NUM": 3, "HP": 2, "LP": 1}, } VALIDATE_LP_I2C = { VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, + VARIANT_ESP32S31: esp32_s31_validate_lp_i2c, } LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) @@ -122,7 +130,7 @@ def validate_config(config): return cv.require_framework_version( esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) )(config) - if CORE.is_rp2040: + if CORE.is_rp2: sda_controller = _rp2040_i2c_controller(config[CONF_SDA]) scl_controller = _rp2040_i2c_controller(config[CONF_SCL]) if sda_controller != scl_controller: @@ -163,7 +171,7 @@ CONFIG_SCHEMA = cv.All( CONF_SDA, esp32="SDA", esp8266="SDA", - rp2040="SDA", + rp2="SDA", nrf52="SDA", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( @@ -173,7 +181,7 @@ CONFIG_SCHEMA = cv.All( CONF_SCL, esp32="SCL", esp8266="SCL", - rp2040="SCL", + rp2="SCL", nrf52="SCL", ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( @@ -183,7 +191,7 @@ CONFIG_SCHEMA = cv.All( CONF_FREQUENCY, esp32="50kHz", esp8266="50kHz", - rp2040="50kHz", + rp2="50kHz", nrf52="100kHz", host="50kHz", ): cv.All( @@ -211,7 +219,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_NRF52, PLATFORM_HOST, ] @@ -225,7 +233,7 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") - if CORE.is_rp2040: + if CORE.is_rp2: if len(full_config) > 2: raise cv.Invalid( "The maximum number of I2C interfaces for RP2040/RP2350 is 2" @@ -435,7 +443,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 47a06abe9e..871f67a4c8 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -19,7 +19,7 @@ void ArduinoI2CBus::setup() { #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // Select Wire instance based on pin assignment, not definition order. // I2C controller = (gpio / 2) % 2: even pairs (0-1,4-5,...) → I2C0, odd pairs (2-3,6-7,...) → I2C1 // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf @@ -41,7 +41,7 @@ void ArduinoI2CBus::setup() { } void ArduinoI2CBus::set_pins_and_clock_() { -#ifdef USE_RP2040 +#ifdef USE_RP2 wire_->setSDA(this->sda_pin_); wire_->setSCL(this->scl_pin_); wire_->begin(); @@ -52,7 +52,7 @@ void ArduinoI2CBus::set_pins_and_clock_() { #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h wire_->setClockStretchLimit(timeout_); // unit: us -#elif defined(USE_RP2040) +#elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h wire_->setTimeout(timeout_ / 1000); // unit: ms #endif @@ -70,7 +70,7 @@ void ArduinoI2CBus::dump_config() { if (timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); -#elif defined(USE_RP2040) +#elif defined(USE_RP2) ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); #endif } diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index edc14af7bc..ded28dd80c 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class ArduinoI2CBus : public InternalI2CBus, public Component { +class ArduinoI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index c23f9f0c54..92e96f649b 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -14,7 +14,7 @@ enum RecoveryCode { RECOVERY_COMPLETED, }; -class IDFI2CBus : public InternalI2CBus, public Component { +class IDFI2CBus final : public InternalI2CBus, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8e3aff7977..8064016a52 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -8,7 +8,7 @@ namespace esphome::i2c { -class HostI2CBus : public I2CBus, public Component { +class HostI2CBus final : public I2CBus, public Component { public: ~HostI2CBus() override; diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3c4aa9ed1d..3ada1e0a0f 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -9,7 +9,7 @@ struct device; // NOLINT(readability-identifier-naming) - forward decl of Zephy namespace esphome::i2c { -class ZephyrI2CBus : public InternalI2CBus, public Component { +class ZephyrI2CBus final : public InternalI2CBus, public Component { public: explicit ZephyrI2CBus(const device *i2c_dev) : i2c_dev_(i2c_dev) {} void setup() override; diff --git a/esphome/components/i2c_device/i2c_device.h b/esphome/components/i2c_device/i2c_device.h index aeae622c2e..d5a49a2caa 100644 --- a/esphome/components/i2c_device/i2c_device.h +++ b/esphome/components/i2c_device/i2c_device.h @@ -5,7 +5,7 @@ namespace esphome::i2c_device { -class I2CDeviceComponent : public Component, public i2c::I2CDevice { +class I2CDeviceComponent final : public Component, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index 6b32b556d9..00a9705807 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -36,7 +36,7 @@ class I2SAudioIn : public I2SAudioBase {}; class I2SAudioOut : public I2SAudioBase {}; -class I2SAudioComponent : public Component { +class I2SAudioComponent final : public Component { public: i2s_std_gpio_config_t get_pin_config() const { return {.mclk = (gpio_num_t) this->mclk_pin_, diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 06f2de7610..65ad7df1af 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -14,7 +14,7 @@ namespace esphome::i2s_audio { -class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, public Component { +class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphone, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 5ba2f4b1a5..6d3c39c68e 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -104,23 +104,26 @@ def _set_stream_limits(config): # stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit. min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8 + # The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it + # in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams + # are accepted rather than forcing an upstream conversion. + max_bits_per_sample = 32 + if config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode can reconfigure the bus to the incoming sample rate and channel count, but the - # configured bits per sample is a hard ceiling: the speaker rejects any stream that exceeds the - # slot bit width it was set up with (see start_i2s_driver), so advertise that as the maximum. + # Primary mode can reconfigure the bus to the incoming sample rate and channel count. audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=16000, max_sample_rate=48000, )(config) else: - # Secondary mode has unmodifiable max bits per sample and min/max sample rates + # Secondary mode has unmodifiable min/max sample rates audio.set_stream_limits( min_bits_per_sample=min_bits_per_sample, - max_bits_per_sample=config[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=max_bits_per_sample, min_channels=1, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 989bcf2977..ed5145d4b0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -404,6 +404,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { this->current_stream_info_ = audio_stream_info; + // SPDIF never narrows the bit depth; the encoder consumes the input format directly. + this->output_stream_info_ = audio_stream_info; // SPDIF mode validation if (this->sample_rate_ != audio_stream_info.get_sample_rate()) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 691f68e912..5e271e671e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -139,7 +139,7 @@ void I2SAudioSpeakerBase::set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { - if (volume > 0.0) { + if (volume > 0.0f) { this->audio_dac_->set_mute_off(); } this->audio_dac_->set_volume(volume); @@ -354,7 +354,7 @@ void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_rea void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { #ifdef USE_ESP32_VARIANT_ESP32 // For ESP32 16-bit mono mode, adjacent samples need to be swapped. - if (this->current_stream_info_.get_channels() == 1 && this->current_stream_info_.get_bits_per_sample() == 16) { + if (this->output_stream_info_.get_channels() == 1 && this->output_stream_info_.get_bits_per_sample() == 16) { int16_t *samples = reinterpret_cast(data); size_t sample_count = bytes_read / sizeof(int16_t); for (size_t i = 0; i + 1 < sample_count; i += 2) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 34792bdbea..adb6ca5e3f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -134,7 +134,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void apply_software_volume_(uint8_t *data, size_t bytes_read); /// @brief Swap adjacent 16-bit mono samples for ESP32 (non-variant) hardware quirk. - /// Only applies when running on original ESP32 with 16-bit mono audio. + /// Only applies when running on original ESP32 with 16-bit mono output. Operates on the data that is + /// handed to the I2S peripheral, so the check uses the output (post-narrowing) stream info. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read); @@ -156,7 +157,11 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public int32_t q31_volume_factor_{INT32_MAX}; - audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info + audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) + // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as + // current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than + // the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts. + audio::AudioStreamInfo output_stream_info_; gpio_num_t dout_pin_; i2s_chan_handle_t tx_handle_{nullptr}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 0afb67fb36..17c93763d6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -13,6 +13,9 @@ #include "esp_timer.h" +// esp-audio-libs +#include + namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; @@ -62,6 +65,12 @@ void I2SAudioSpeaker::dump_config() { break; } ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + // The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to + // it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this + // is not necessarily the sample data width (which depends on the incoming stream). + ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast(this->slot_bit_width_)); + } } void I2SAudioSpeaker::run_speaker_task() { @@ -71,12 +80,19 @@ void I2SAudioSpeaker::run_speaker_task() { // Ensure ring buffer duration is at least the duration of all DMA buffers const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_); - // The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info + // The ring buffer holds input-format audio (what play() receives), so size it from the input stream info. const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1); // Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame; + + // Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral. + const uint8_t channels = this->current_stream_info_.get_channels(); + const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8; + const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8; + const bool narrowing = input_bytes_per_sample != output_bytes_per_sample; + // ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it // clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through // the L1 cache (e.g. ESP32-P4), rounds the buffer to the cache line. Read the size the driver actually @@ -89,9 +105,12 @@ void I2SAudioSpeaker::run_speaker_task() { dma_buffer_bytes = chan_info.total_dma_buf_size / DMA_BUFFERS_COUNT; } else { // Should not happen for a READY channel; fall back to the requested size. - dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(dma_buffer_frames(this->current_stream_info_)); + dma_buffer_bytes = this->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); } - const uint32_t frames_per_dma_buffer = this->current_stream_info_.bytes_to_frames(dma_buffer_bytes); + // dma_buffer_bytes counts output-format bytes; convert with the output stream info. + const uint32_t frames_per_dma_buffer = this->output_stream_info_.bytes_to_frames(dma_buffer_bytes); + // Soft cap for each source read: enough input-format bytes to fill one DMA buffer's worth of frames. + const size_t dma_buffer_input_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); bool successful_setup = false; @@ -105,8 +124,8 @@ void I2SAudioSpeaker::run_speaker_task() { memset(silence_buffer, 0, dma_buffer_bytes); std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); - audio_source = - audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast(bytes_per_frame)); + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_input_bytes, + static_cast(bytes_per_frame)); if (audio_source != nullptr) { // audio_source is nullptr if the ring buffer fails to allocate @@ -237,42 +256,61 @@ void I2SAudioSpeaker::run_speaker_task() { // Compose exactly one DMA buffer's worth: drain as much real audio as the source currently // exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any // remainder with silence. All writes pack into the next free DMA descriptor in order, so the - // descriptor ends up holding [real audio][silence padding]. + // descriptor ends up holding [real audio][silence padding]. ``bytes_written_total`` counts + // output-format bytes so it tracks how full the DMA buffer is regardless of any narrowing. size_t bytes_written_total = 0; - size_t real_bytes_total = 0; + uint32_t real_frames_total = 0; bool partial_write_failure = false; if (!this->pause_state_) { while (bytes_written_total < dma_buffer_bytes) { size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false); if (bytes_read > 0) { + // Apply volume at the input bit depth, before any narrowing, so the full precision is scaled. uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read; this->apply_software_volume_(new_data, bytes_read); - this->swap_esp32_mono_samples_(new_data, bytes_read); } - const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total); - if (to_write == 0) { + // Convert as many whole frames as fit in the remaining DMA space, bounded by what the source + // currently exposes. Frame counts are shared between input and output; only the byte widths differ. + const uint32_t frames_available = this->current_stream_info_.bytes_to_frames(audio_source->available()); + const uint32_t frames_room = + this->output_stream_info_.bytes_to_frames(dma_buffer_bytes - bytes_written_total); + const uint32_t frames_to_write = std::min(frames_available, frames_room); + if (frames_to_write == 0) { // Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer // with silence so the lockstep invariant (one write per iteration) is preserved. break; } + const size_t input_bytes = this->current_stream_info_.frames_to_bytes(frames_to_write); + const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write); + + uint8_t *chunk = audio_source->mutable_data(); + if (narrowing) { + // Narrow the bit depth in place: output exactly aliases input with the same channel count and a + // smaller width, which copy_frames handles as a single forward pass. Only the frames about to be + // consumed are overwritten, so any unprocessed tail stays intact for the next iteration. + esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels, + output_bytes_per_sample, channels, frames_to_write); + } + this->swap_esp32_mono_samples_(chunk, output_bytes); + size_t bw = 0; - i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS); - if (bw != to_write) { + i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); + if (bw != output_bytes) { // A short real-audio write breaks DMA descriptor alignment for every subsequent event; // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write); + ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); partial_write_failure = true; break; } - audio_source->consume(bw); - bytes_written_total += bw; - real_bytes_total += bw; + audio_source->consume(input_bytes); + bytes_written_total += output_bytes; + real_frames_total += frames_to_write; } - if (real_bytes_total > 0) { + if (real_frames_total > 0) { last_data_received_time = millis(); } } @@ -293,16 +331,15 @@ void I2SAudioSpeaker::run_speaker_task() { } } - const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total); // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep // invariant is broken and every subsequent timestamp would be silently wrong, so bail. - if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) { + if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { ESP_LOGV(TAG, "Exiting: write records queue full"); xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); break; } - if (real_frames_in_buffer > 0) { + if (real_frames_total > 0) { pending_real_buffers++; } } @@ -334,21 +371,28 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_NOT_SUPPORTED; } - if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO && - (i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) { - // Currently can't handle the case when the incoming audio has more bits per sample than the configured value - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; + // When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place + // before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and + // the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed + // through unchanged (the slot may still be wider than the data, the existing behavior). + uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) { + const uint8_t configured_bits = static_cast(this->slot_bit_width_); + if (output_bits_per_sample > configured_bits) { + output_bits_per_sample = configured_bits; + } } + this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(), + audio_stream_info.get_sample_rate()); #ifdef USE_ESP32_VARIANT_ESP32 // The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample // occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples // tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple - // of 16, so reject anything else rather than emit corrupted audio. - if (audio_stream_info.get_bits_per_sample() % 16 != 0) { - ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit audio, got %u-bit", - (unsigned) audio_stream_info.get_bits_per_sample()); + // of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine + // as long as it narrows to a 16- or 32-bit slot. + if (output_bits_per_sample % 16 != 0) { + ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } #endif // USE_ESP32_VARIANT_ESP32 @@ -358,7 +402,8 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = dma_buffer_frames(audio_stream_info); + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. + uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); i2s_role_t i2s_role = this->i2s_role_; i2s_clock_src_t clk_src = I2S_CLK_SRC_DEFAULT; @@ -398,19 +443,18 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream slot_mask = I2S_STD_SLOT_BOTH; } + // Configure the data bit width from the output (post-narrowing) format, which is what is clocked out. + const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) this->output_stream_info_.get_bits_per_sample(); i2s_std_slot_config_t slot_cfg; switch (this->i2s_comm_fmt_) { case I2SCommFmt::PCM: - slot_cfg = - I2S_STD_PCM_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_PCM_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; case I2SCommFmt::MSB: - slot_cfg = - I2S_STD_MSB_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), slot_mode); + slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; default: - slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG((i2s_data_bit_width_t) audio_stream_info.get_bits_per_sample(), - slot_mode); + slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(data_bit_width, slot_mode); break; } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h index 7b7f8b647d..4b52dcd52a 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.h @@ -14,7 +14,7 @@ enum class I2SCommFmt : uint8_t { /// @brief Standard I2S speaker implementation. /// Outputs PCM audio data directly to an I2S DAC using the standard I2S protocol. -class I2SAudioSpeaker : public I2SAudioSpeakerBase { +class I2SAudioSpeaker final : public I2SAudioSpeakerBase { public: void dump_config() override; diff --git a/esphome/components/iaqcore/iaqcore.h b/esphome/components/iaqcore/iaqcore.h index 39f290e120..6fdf9cbce8 100644 --- a/esphome/components/iaqcore/iaqcore.h +++ b/esphome/components/iaqcore/iaqcore.h @@ -6,7 +6,7 @@ namespace esphome::iaqcore { -class IAQCore : public PollingComponent, public i2c::I2CDevice { +class IAQCore final : public PollingComponent, public i2c::I2CDevice { public: void set_co2(sensor::Sensor *co2) { co2_ = co2; } void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 5f8e5ca132..37a9afb84d 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,38 +1,27 @@ from __future__ import annotations -import contextlib +from collections.abc import Callable from dataclasses import dataclass -import hashlib -import io import logging from pathlib import Path -import re from PIL import Image, UnidentifiedImageError -from esphome import core, external_files import esphome.codegen as cg from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv -from esphome.const import ( - CONF_DEFAULTS, - CONF_DITHER, - CONF_FILE, - CONF_ICON, - CONF_ID, - CONF_PATH, - CONF_RAW_DATA_ID, - CONF_RESIZE, - CONF_SOURCE, - CONF_TYPE, - CONF_URL, -) -from esphome.core import CORE, HexInt +from esphome.const import CONF_DEFAULTS, CONF_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) DOMAIN = "image" DEPENDENCIES = ["display"] +IS_PLATFORM_COMPONENT = True + +# Name of the built-in static-image platform (local file / web / MDI sources). +PLATFORM_FILE = "file" image_ns = cg.esphome_ns.namespace("image") @@ -135,17 +124,6 @@ class ImageEncoder: """ return False - @classmethod - def get_options(cls) -> list[str]: - """ - Get the available options for this image encoder - """ - options = [*OPTIONS] - if not cls.is_endian(): - options.remove(CONF_BYTE_ORDER) - options.append(CONF_RAW_DATA_ID) - return options - def is_alpha_only(image: Image): """ @@ -338,60 +316,11 @@ TransparencyType = image_ns.enum("TransparencyType") CONF_TRANSPARENCY = "transparency" -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - -SOURCE_LOCAL = "local" -SOURCE_WEB = "web" - -SOURCE_MDI = "mdi" -SOURCE_MDIL = "mdil" -SOURCE_MEMORY = "memory" - -MDI_SOURCES = { - SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/", - SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/", - SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", -} - Image_ = image_ns.class_("Image") INSTANCE_TYPE = Image_ -def compute_local_image_path(value) -> Path: - url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key - - -def local_path(value): - value = value[CONF_PATH] if isinstance(value, dict) else value - return str(CORE.relative_config_path(value)) - - -def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) - return str(path) - - -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value - base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" - - url = MDI_SOURCES[source] + mdi_id + ".svg" - return download_file(url, path) - - -def download_image(value): - value = value[CONF_URL] if isinstance(value, dict) else value - return download_file(value, compute_local_image_path(value)) - - def is_svg_file(file): if not file: return False @@ -399,62 +328,6 @@ def is_svg_file(file): return " 500 or height > 500): - _LOGGER.warning( - 'The image "%s" you requested is very big. Please consider' - " using the resize parameter.", - path, - ) - - dither = ( - Image.Dither.NONE - if config[CONF_DITHER] == "NONE" - else Image.Dither.FLOYDSTEINBERG - ) - type = config[CONF_TYPE] - transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE) - invert_alpha = config[CONF_INVERT_ALPHA] - frame_count = 1 - if all_frames: - with contextlib.suppress(AttributeError): - frame_count = image.n_frames - if frame_count <= 1: - _LOGGER.warning("Image file %s has no animation frames", path) - - # Encode each frame with its own encoder and concatenate. This keeps every - # frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane] - # per frame) so animation frame stepping in image.cpp / animation.cpp stays - # correct without needing to know the total frame count. - byte_order = config.get(CONF_BYTE_ORDER) - combined_data: list[int] = [] - encoder: ImageEncoder | None = None - for frame_index in range(frame_count): - image.seek(frame_index) - encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha) - if byte_order is not None: - # Check for valid type has already been done in validate_settings - encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() - for row in range(height): - for col in range(width): - encoder.encode(pixels[row * width + col]) - encoder.end_row() - encoder.end_image() - combined_data.extend(encoder.data) - - rhs = [HexInt(x) for x in combined_data] - prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) - image_type = get_image_type_enum(type) - trans_value = get_transparency_enum(encoder.transparency) - - return prog_arr, width, height, image_type, trans_value, frame_count - - def add_metadata(id: str, width: int, height: int, image_type: str, transparency): all_metadata = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) all_metadata[str(id)] = ImageMetaData( @@ -780,17 +388,10 @@ def add_metadata(id: str, width: int, height: int, image_type: str, transparency ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + # Base platform-component codegen: each entry is generated by its platform's + # own ``to_code``; here we only need the feature define to be present. cg.add_define("USE_IMAGE") - # By now the config will be a simple list. - for entry in config: - prog_arr, width, height, image_type, trans_value, _ = await write_image(entry) - cg.new_Pvariable( - entry[CONF_ID], prog_arr, width, height, image_type, trans_value - ) - add_metadata( - entry[CONF_ID], width, height, entry[CONF_TYPE], entry[CONF_TRANSPARENCY] - ) def get_all_image_metadata() -> dict[str, ImageMetaData]: @@ -801,3 +402,198 @@ def get_all_image_metadata() -> dict[str, ImageMetaData]: def get_image_metadata(image_id: str) -> ImageMetaData | None: """Get image metadata by ID for use by other components.""" return get_all_image_metadata().get(image_id) + + +# --------------------------------------------------------------------------- +# Legacy top-level component -> `image:` platform deprecation helpers +# -- REMOVE after 2027.1.0 together with the `animation:`/`online_image:` shims. +# +# `animation:` and `online_image:` used to be standalone top-level components and +# are now platforms of `image:`. Their deprecated top-level shims use this helper +# to (1) record each raw entry as it is validated and (2) print a single, +# pasteable migrated `image:` block once every entry has been seen. The block is +# emitted from FINAL_VALIDATE_SCHEMA, which always runs after every per-entry +# CONFIG_SCHEMA step, so all entries are captured before it fires. +# --------------------------------------------------------------------------- + + +def legacy_platform_migration_warning( + domain: str, platform: str, removal_version: str +) -> tuple[ + Callable[[ConfigType], ConfigType], + Callable[[ConfigType], ConfigType], +]: + """Build the per-entry capture and one-shot warning validators for a + deprecated top-level component that is now an ``image:`` platform. + + Returns ``(capture, finalize)``: + * ``capture`` is a ``CONFIG_SCHEMA`` validator placed *before* the real + schema so it sees the raw user entry; it records a copy of each entry. + * ``finalize`` is a ``FINAL_VALIDATE_SCHEMA`` validator that warns exactly + once with the migrated, pasteable ``image:`` block. + """ + entries_key = "legacy_entries" + shown_key = "legacy_warning_shown" + + def capture(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + data.setdefault(entries_key, []).append(dict(config)) + return config + + def finalize(config: ConfigType) -> ConfigType: + data = CORE.data.setdefault(domain, {}) + if not data.get(shown_key): + data[shown_key] = True + + from esphome import yaml_util + + migrated = [ + {CONF_PLATFORM: platform, **entry} + for entry in data.get(entries_key, []) + ] + _LOGGER.warning( + "The top-level '%s:' configuration is deprecated and will be " + "removed in ESPHome %s. '%s' is now a platform of the 'image' " + "component. Replace your '%s:' block with:\n\n%s", + domain, + removal_version, + domain, + domain, + yaml_util.dump({DOMAIN: migrated}), + ) + return config + + return capture, finalize + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE after 2027.1.0 +# +# Before `image` became a platform component, its top-level config was either a +# bare list of image dicts, a single image dict, or a dict with `defaults:`, +# `images:` and per-type group keys. This block transparently rewrites those +# forms into the new ``platform: file`` list and prints the migrated YAML. +# It is intentionally self-contained so it can be deleted in one piece together +# with the ``LEGACY_CONFIG_MIGRATE`` assignment below. +# --------------------------------------------------------------------------- + +LEGACY_REMOVAL_VERSION = "2027.1.0" + + +def _is_new_image_format(config: object) -> bool: + """True when the config is already the new ``platform:``-tagged list.""" + return isinstance(config, list) and all( + isinstance(entry, dict) and CONF_PLATFORM in entry for entry in config + ) + + +def _is_legacy_image_format(config: object) -> bool: + """True when ``config`` matches a shape the pre-platform schema accepted. + + Only these shapes are migrated. Anything else -- a list containing a + non-dict (or already platform-tagged) entry, or a dict with no recognised + image keys -- is left untouched so the platform validation surfaces a + proper error instead of the migration silently dropping the input. + """ + if isinstance(config, list): + # A bare list of (not-yet-platform-tagged) image dicts. + return bool(config) and all( + isinstance(entry, dict) and CONF_PLATFORM not in entry for entry in config + ) + if not isinstance(config, dict): + return False + # A single image dict, or the grouped `defaults:`/`images:`/type-key form. + return ( + CONF_ID in config + or CONF_FILE in config + or any( + key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() in IMAGE_TYPE + for key in config + ) + ) + + +def _flatten_legacy_image_config(config: object) -> list[dict]: + """Structurally flatten a legacy ``image:`` config into image dicts. + + No validation or file IO is performed -- the ``file`` platform schema + validates the resulting entries. Unrecognised shapes yield no entries so the + normal platform validation surfaces the error. + """ + if isinstance(config, list): + return [dict(entry) for entry in config if isinstance(entry, dict)] + if not isinstance(config, dict): + return [] + if CONF_ID in config or CONF_FILE in config: + return [dict(config)] + + defaults = config.get(CONF_DEFAULTS) or {} + result: list[dict] = [] + + def _add(entry: dict, extra: dict) -> None: + merged = {**defaults, **extra, **entry} + # The legacy `defaults:`/type-grouped forms only applied `byte_order` to + # types that support it. Replicate that so an endian default merged into + # e.g. a binary image stays valid. + type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) + if ( + CONF_BYTE_ORDER in merged + and isinstance(type_class, type) + and issubclass(type_class, ImageEncoder) + and not type_class.is_endian() + ): + del merged[CONF_BYTE_ORDER] + result.append(merged) + + def _add_entries(entries: object, extra: dict) -> None: + # `entries` may be a single image dict or a list of them; non-dict + # members are silently skipped, mirroring the old `ensure_list` leniency. + for entry in [entries] if isinstance(entries, dict) else entries: + if isinstance(entry, dict): + _add(entry, extra) + + _add_entries(config.get(CONF_IMAGES, []), {}) + + for key, value in config.items(): + if key in (CONF_DEFAULTS, CONF_IMAGES) or key.upper() not in IMAGE_TYPE: + continue + type_extra = {CONF_TYPE: key} + if isinstance(value, dict) and ( + transparency_keys := [k for k in value if k in TRANSPARENCY_TYPES] + ): + for trans in transparency_keys: + _add_entries(value[trans], {**type_extra, CONF_TRANSPARENCY: trans}) + elif isinstance(value, (list, dict)): + _add_entries(value, type_extra) + return result + + +def _migrate_legacy_image_config(config: object) -> list[dict] | None: + """Rewrite a legacy ``image:`` config into the ``platform: file`` list. + + Returns None for the already-migrated platform form and for any shape the + pre-platform schema never accepted, so normal platform validation can + surface a proper error instead of the migration silently discarding input. + """ + if _is_new_image_format(config) or not _is_legacy_image_format(config): + return None + migrated = [ + {CONF_PLATFORM: PLATFORM_FILE, **entry} + for entry in _flatten_legacy_image_config(config) + ] + + from esphome import yaml_util + + _LOGGER.warning( + "The 'image:' configuration format is deprecated and will be removed in " + "ESPHome %s. Images are now platforms of the 'image' component. Replace " + "your 'image:' block with:\n\n%s", + LEGACY_REMOVAL_VERSION, + yaml_util.dump({DOMAIN: migrated}), + ) + return migrated + + +LEGACY_CONFIG_MIGRATE = _migrate_legacy_image_config + +# --------------------------- end legacy migration -------------------------- diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index c95b693cf0..9b603683ab 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -123,26 +123,18 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { break; case IMAGE_TYPE_RGB: -#if LV_COLOR_DEPTH == 32 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_ALPHA; + this->dsc_.header.cf = LV_COLOR_FORMAT_ARGB8888; break; case TRANSPARENCY_CHROMA_KEY: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR_CHROMA_KEYED; - break; default: - this->dsc_.header.cf = LV_IMG_CF_TRUE_COLOR; + this->dsc_.header.cf = LV_COLOR_FORMAT_RGB888; break; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_COLOR_FORMAT_ARGB8888 : LV_COLOR_FORMAT_RGB888; -#endif break; case IMAGE_TYPE_RGB565: -#if LV_COLOR_DEPTH == 16 switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565A8; @@ -150,10 +142,6 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { default: this->dsc_.header.cf = LV_COLOR_FORMAT_RGB565; } -#else - this->dsc_.header.cf = - this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? LV_IMG_CF_RGB565A8 : LV_IMG_CF_RGB565; -#endif break; } } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 70f9214e2d..4df6f6df2d 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -44,7 +44,7 @@ enum ImprovSerialType : uint8_t { static const uint16_t IMPROV_SERIAL_TIMEOUT = 100; static const uint8_t IMPROV_SERIAL_VERSION = 1; -class ImprovSerialComponent : public Component, public improv_base::ImprovBase { +class ImprovSerialComponent final : public Component, public improv_base::ImprovBase { public: void setup() override; void loop() override; diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 85da196584..833d1989c8 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -119,7 +119,7 @@ void INA219Component::setup() { } this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.04096f / (0.000001 * lsb * this->shunt_resistance_ohm_)); + auto calibration = uint32_t(0.04096f / (0.000001f * lsb * this->shunt_resistance_ohm_)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); if (!this->write_byte_16(INA219_REGISTER_CALIBRATION, calibration)) { this->mark_failed(); diff --git a/esphome/components/ina219/ina219.h b/esphome/components/ina219/ina219.h index 7462c07272..a78c1653f4 100644 --- a/esphome/components/ina219/ina219.h +++ b/esphome/components/ina219/ina219.h @@ -8,7 +8,7 @@ namespace esphome::ina219 { -class INA219Component : public PollingComponent, public i2c::I2CDevice { +class INA219Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index 695de57c61..c22237d144 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -70,7 +70,7 @@ void INA226Component::setup() { this->calibration_lsb_ = lsb; - auto calibration = uint32_t(0.00512 / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); + auto calibration = uint32_t(0.00512f / (lsb * this->shunt_resistance_ohm_ / 1000000.0f)); ESP_LOGV(TAG, " Using LSB=%" PRIu32 " calibration=%" PRIu32, lsb, calibration); diff --git a/esphome/components/ina226/ina226.h b/esphome/components/ina226/ina226.h index 7d6b526f40..00d62fad76 100644 --- a/esphome/components/ina226/ina226.h +++ b/esphome/components/ina226/ina226.h @@ -40,7 +40,7 @@ union ConfigurationRegister { } __attribute__((packed)); }; -class INA226Component : public PollingComponent, public i2c::I2CDevice { +class INA226Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina260/ina260.h b/esphome/components/ina260/ina260.h index 856e715774..bbcb7a7acb 100644 --- a/esphome/components/ina260/ina260.h +++ b/esphome/components/ina260/ina260.h @@ -6,7 +6,7 @@ namespace esphome::ina260 { -class INA260Component : public PollingComponent, public i2c::I2CDevice { +class INA260Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.h b/esphome/components/ina2xx_i2c/ina2xx_i2c.h index 783723b396..d9945be5ef 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.h +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ina2xx_i2c { -class INA2XXI2C : public ina2xx_base::INA2XX, public i2c::I2CDevice { +class INA2XXI2C final : public ina2xx_base::INA2XX, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.h b/esphome/components/ina2xx_spi/ina2xx_spi.h index 8e065de816..efe9cf257d 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.h +++ b/esphome/components/ina2xx_spi/ina2xx_spi.h @@ -6,9 +6,9 @@ namespace esphome::ina2xx_spi { -class INA2XXSPI : public ina2xx_base::INA2XX, - public spi::SPIDevice { +class INA2XXSPI final : public ina2xx_base::INA2XX, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ina3221/ina3221.h b/esphome/components/ina3221/ina3221.h index 9d9762caf3..48226c743a 100644 --- a/esphome/components/ina3221/ina3221.h +++ b/esphome/components/ina3221/ina3221.h @@ -6,7 +6,7 @@ namespace esphome::ina3221 { -class INA3221Component : public PollingComponent, public i2c::I2CDevice { +class INA3221Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 37e50943f3..4c90d6d35b 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -8,7 +8,7 @@ namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/inkplate/inkplate.h b/esphome/components/inkplate/inkplate.h index 40e32c4cc4..4f9f4109ee 100644 --- a/esphome/components/inkplate/inkplate.h +++ b/esphome/components/inkplate/inkplate.h @@ -31,7 +31,7 @@ static constexpr uint8_t LUTB[16] = {0xFF, 0xFD, 0xF7, 0xF5, 0xDF, 0xDD, 0xD7, 0 static constexpr uint8_t PIXEL_MASK_LUT[8] = {0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80}; static constexpr uint8_t PIXEL_MASK_GLUT[2] = {0x0F, 0xF0}; -class Inkplate : public display::DisplayBuffer, public i2c::I2CDevice { +class Inkplate final : public display::DisplayBuffer, public i2c::I2CDevice { public: void set_greyscale(bool greyscale) { this->greyscale_ = greyscale; diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 1c5edfcba5..019c3ee074 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -22,7 +22,7 @@ enum IntegrationMethod { INTEGRATION_METHOD_RIGHT, }; -class IntegrationSensor : public sensor::Sensor, public Component { +class IntegrationSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; @@ -71,12 +71,12 @@ class IntegrationSensor : public sensor::Sensor, public Component { float last_value_{0.0f}; }; -template class ResetAction : public Action, public Parented { +template class ResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->reset(); } }; -template class SetValueAction : public Action, public Parented { +template class SetValueAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, value) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 41fea5a255..90831cf211 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -11,7 +11,7 @@ namespace esphome::internal_temperature { -class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { +class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 1c44a9a238..64fe3707b1 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -3,17 +3,16 @@ #include "esphome/core/log.h" #include "internal_temperature.h" +#include + #if defined(USE_ESP32_VARIANT_ESP32) // there is no official API available on the original ESP32 extern "C" { uint8_t temprature_sens_read(); } -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED #include "driver/temperature_sensor.h" -#endif // USE_ESP32_VARIANT +#endif namespace esphome::internal_temperature { @@ -27,10 +26,7 @@ void InternalTemperatureSensor::update() { ESP_LOGV(TAG, "Raw temperature value: %d", raw); temperature = (raw - 32) / 1.8f; success = (raw != 128); -#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) +#elif SOC_TEMP_SENSOR_SUPPORTED esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { @@ -49,9 +45,7 @@ void InternalTemperatureSensor::update() { } void InternalTemperatureSensor::setup() { -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if SOC_TEMP_SENSOR_SUPPORTED temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp similarity index 85% rename from esphome/components/internal_temperature/internal_temperature_rp2040.cpp rename to esphome/components/internal_temperature/internal_temperature_rp2.cpp index 66dee9faf7..11f8e27fc3 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/log.h" #include "internal_temperature.h" @@ -7,7 +7,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.rp2040"; +static const char *const TAG = "internal_temperature.rp2"; void InternalTemperatureSensor::update() { float temperature = NAN; @@ -28,4 +28,4 @@ void InternalTemperatureSensor::update() { } // namespace esphome::internal_temperature -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 02730b6862..805138071e 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -10,7 +10,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, PlatformFramework, @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on( [ PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_NRF52, PLATFORM_LN882X, @@ -58,7 +58,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, }, - "internal_temperature_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "internal_temperature_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "internal_temperature_bk72xx.cpp": { PlatformFramework.BK72XX_ARDUINO, }, diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index c9d4e8ea3e..fd59d2a488 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -6,7 +6,7 @@ namespace esphome::interval { -class IntervalTrigger : public Trigger<>, public PollingComponent { +class IntervalTrigger final : public Trigger<>, public PollingComponent { public: void update() override { this->trigger(); } diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index d0467e822d..5fc683354b 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -18,7 +18,7 @@ namespace esphome::ir_rf_proxy { #ifdef USE_IR_RF /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend -class IrRfProxy : public infrared::Infrared { +class IrRfProxy final : public infrared::Infrared { public: IrRfProxy() = default; @@ -47,7 +47,7 @@ class IrRfProxy : public infrared::Infrared { /// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done /// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and /// to this entity's on_control trigger (see radio_frequency component docs). -class RfProxy : public radio_frequency::RadioFrequency { +class RfProxy final : public radio_frequency::RadioFrequency { public: RfProxy() = default; diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py new file mode 100644 index 0000000000..7fc4ae2cd0 --- /dev/null +++ b/esphome/components/it8951/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py new file mode 100644 index 0000000000..51c5fc6118 --- /dev/null +++ b/esphome/components/it8951/display.py @@ -0,0 +1,433 @@ +""" +ESPHome configuration for the IT8951 e-paper controller. +""" + +from esphome import automation, core, pins +import esphome.codegen as cg +from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation +import esphome.config_validation as cv +from esphome.config_validation import update_interval +from esphome.const import ( + CONF_BUSY_PIN, + CONF_CS_PIN, + CONF_DATA_RATE, + CONF_DIMENSIONS, + CONF_ENABLE_PIN, + CONF_FULL_UPDATE_EVERY, + CONF_HEIGHT, + CONF_ID, + CONF_INVERT_COLORS, + CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_MODE, + CONF_MODEL, + CONF_PAGES, + CONF_RESET_DURATION, + CONF_RESET_PIN, + CONF_ROTATION, + CONF_SLEEP_WHEN_DONE, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_UPDATE_INTERVAL, + CONF_WIDTH, +) +from esphome.cpp_generator import RawExpression +from esphome.final_validate import full_config + +AUTO_LOAD = ["split_buffer"] +DEPENDENCIES = ["spi"] + +CONF_VCOM = "vcom" +CONF_VCOM_REGISTER = "vcom_register" +CONF_FORCE_TEMPERATURE = "force_temperature" +CONF_GRAYSCALE = "grayscale" +CONF_DITHERING = "dithering" +CONF_UPDATE_MODE = "update_mode" +CONF_USE_LEGACY_DPY_AREA = "use_legacy_dpy_area" + +# VCOM SET sub-command selectors. The IT8951 firmware accepts different +# values across panels; most respond to 0x0001, but a few — e.g. the Seeed +# reTerminal E1003 — only respond to 0x0002 and silently drop 0x0001. +VCOM_REGISTER_DEFAULT = 0x0001 +VCOM_REGISTER_ALT = 0x0002 +VCOM_REGISTER_OPTIONS = (VCOM_REGISTER_DEFAULT, VCOM_REGISTER_ALT) + +it8951_ns = cg.esphome_ns.namespace("it8951") +IT8951Display = it8951_ns.class_("IT8951Display", display.Display, spi.SPIDevice) +IT8951UpdateAction = it8951_ns.class_("IT8951UpdateAction", automation.Action) + +# Hardware waveform modes exposed to YAML. Strings are mapped to the C++ +# UpdateMode enum so the runtime can store the mode as a uint16_t rather +# than a std::string (avoiding a heap-resident member; see ESPHome +# CLAUDE.md "STL Container Guidelines"). "fast" and "full" are +# convenience aliases for DU and GC16 respectively. +UpdateMode = it8951_ns.enum("UpdateMode") +UPDATE_MODE_OPTIONS = { + "INIT": UpdateMode.UPDATE_MODE_INIT, + "DU": UpdateMode.UPDATE_MODE_DU, + "GC16": UpdateMode.UPDATE_MODE_GC16, + "GL16": UpdateMode.UPDATE_MODE_GL16, + "GLR16": UpdateMode.UPDATE_MODE_GLR16, + "GLD16": UpdateMode.UPDATE_MODE_GLD16, + "DU4": UpdateMode.UPDATE_MODE_DU4, + "A2": UpdateMode.UPDATE_MODE_A2, + "FAST": UpdateMode.UPDATE_MODE_DU, + "FULL": UpdateMode.UPDATE_MODE_GC16, +} +# Maps the YAML mode string directly to the C++ UpdateMode enum value, so the +# config option and the it8951.update action share one validator. +update_mode = cv.enum(UPDATE_MODE_OPTIONS, upper=True) + +# Transform flag values mirror the C++ TRANSFORM_* constants. +_TRANSFORM_NONE = 0 +_TRANSFORM_MIRROR_X = 1 +_TRANSFORM_MIRROR_Y = 2 +_TRANSFORM_SWAP_XY = 4 +_TRANSFORM_FLAGS = { + CONF_MIRROR_X: _TRANSFORM_MIRROR_X, + CONF_MIRROR_Y: _TRANSFORM_MIRROR_Y, + CONF_SWAP_XY: _TRANSFORM_SWAP_XY, +} + + +class IT8951Model: + """A specific board / panel preset for the IT8951 controller.""" + + models: dict[str, "IT8951Model"] = {} + + def __init__(self, name: str, **defaults): + name = name.upper() + self.name = name + self.defaults = defaults + IT8951Model.models[name] = self + + def get_default(self, key, fallback=None): + return self.defaults.get(key, fallback) + + def get_dimensions(self, config) -> tuple[int, int]: + # If dimensions are in config, use them; otherwise fall back to model defaults. + if CONF_DIMENSIONS in config: + dimensions = config[CONF_DIMENSIONS] + if isinstance(dimensions, dict): + return dimensions[CONF_WIDTH], dimensions[CONF_HEIGHT] + return tuple(dimensions) + # Model must have defaults if dimensions not in config. + return self.get_default(CONF_WIDTH), self.get_default(CONF_HEIGHT) + + +# --- Model presets ---------------------------------------------------------- +# The generic model leaves dimensions and pin choices up to the user. +IT8951Model("it8951", vcom=2300, sleep_when_done=True, data_rate=12_000_000) + +IT8951Model( + "m5stack-m5paper", + width=960, + height=540, + busy_pin=27, + reset_pin=23, + cs_pin=15, + vcom=2300, + sleep_when_done=True, + data_rate=20_000_000, +) + +IT8951Model( + "seeed-reterminal-e1003", + width=1872, + height=1404, + busy_pin=13, + reset_pin=12, + cs_pin=10, + # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply + # (GPIO11). Driven high during setup so no separate power_supply is needed. + enable_pin=[21, 11], + vcom=1400, + # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET + # selector; using the default 0x0001 leaves VCOM unchanged and breaks + # grayscale waveforms (GC16/GL16) — INIT still works because it does + # not depend on VCOM accuracy. + vcom_register=VCOM_REGISTER_ALT, + # The reTerminal E1003 ships with on-die temperature sensing disabled, + # so the host must declare an operating temperature; otherwise the + # waveform LUT defaults to a value that produces no visible change + # for grayscale modes. + force_temperature=25, + sleep_when_done=False, + data_rate=20_000_000, + mirror_x=True, +) + +IT8951Model( + "seeed-ee03", + width=1872, + height=1404, + busy_pin=4, + reset_pin=38, + cs_pin=44, + vcom=1400, + sleep_when_done=False, + data_rate=4_000_000, +) + +# --------------------------------------------------------------------------- + +DIMENSION_SCHEMA = cv.Schema( + { + cv.Required(CONF_WIDTH): cv.int_, + cv.Required(CONF_HEIGHT): cv.int_, + } +) + + +def _model_pin_option(model, key, schema): + default = model.get_default(key) + if default is None: + return cv.Required(key), schema + return cv.Optional(key, default=default), schema + + +def _model_schema(config): + model = IT8951Model.models[config[CONF_MODEL]] + has_default_dimensions = ( + model.get_default(CONF_WIDTH) is not None + and model.get_default(CONF_HEIGHT) is not None + ) + dimensions_key = ( + cv.Optional( + CONF_DIMENSIONS, + default={ + CONF_WIDTH: model.get_default(CONF_WIDTH), + CONF_HEIGHT: model.get_default(CONF_HEIGHT), + }, + ) + if has_default_dimensions + else cv.Required(CONF_DIMENSIONS) + ) + + schema = display.FULL_DISPLAY_SCHEMA.extend( + spi.spi_device_schema( + cs_pin_required=False, + default_mode="MODE0", + default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), + ) + ).extend( + { + cv.GenerateID(): cv.declare_id(IT8951Display), + cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True, space="-"), + cv.Optional(CONF_ROTATION, default=0): validate_rotation, + cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): update_interval, + cv.Optional(CONF_FULL_UPDATE_EVERY, default=30): cv.int_range(1, 255), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional( + CONF_INVERT_COLORS, default=model.get_default(CONF_INVERT_COLORS, False) + ): cv.boolean, + cv.Optional( + CONF_SLEEP_WHEN_DONE, + default=model.get_default(CONF_SLEEP_WHEN_DONE, False), + ): cv.boolean, + # Pixel format: true = 4bpp grayscale, false = packed 1bpp + # monochrome. Monochrome halves the framebuffer and enables fast DU + # partial refreshes; grayscale gives 16 levels but always uses GC16. + cv.Optional( + CONF_GRAYSCALE, default=model.get_default(CONF_GRAYSCALE, True) + ): cv.boolean, + # Monochrome only: ordered-dither pale colours so they render as + # visible stipple. Disable for a crisp hard black/white threshold + # (better for purely black/white text). No effect in grayscale mode. + cv.Optional( + CONF_DITHERING, default=model.get_default(CONF_DITHERING, True) + ): cv.boolean, + cv.Optional( + CONF_VCOM, default=model.get_default(CONF_VCOM, 2300) + ): cv.int_range(0, 5000), + cv.Optional( + CONF_VCOM_REGISTER, + default=model.get_default(CONF_VCOM_REGISTER, VCOM_REGISTER_DEFAULT), + ): cv.one_of(*VCOM_REGISTER_OPTIONS, int=True), + **( + { + cv.Optional( + CONF_FORCE_TEMPERATURE, + default=model.get_default(CONF_FORCE_TEMPERATURE), + ): cv.int_range(min=-40, max=85) + } + if model.get_default(CONF_FORCE_TEMPERATURE) is not None + else {} + ), + cv.Optional( + CONF_USE_LEGACY_DPY_AREA, + default=model.get_default(CONF_USE_LEGACY_DPY_AREA, False), + ): cv.boolean, + cv.Optional(CONF_UPDATE_MODE): update_mode, + # One or more GPIOs driven high during setup to power on the panel + # (e.g. board power-enable rails), before reset and init. + cv.Optional( + CONF_ENABLE_PIN, default=model.get_default(CONF_ENABLE_PIN, []) + ): cv.ensure_list(pins.gpio_output_pin_schema), + cv.Optional(CONF_RESET_DURATION): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=core.TimePeriod(milliseconds=500)), + ), + dimensions_key: DIMENSION_SCHEMA, + } + ) + + # Pin options: required if the model doesn't supply a default. + pin_specs = ( + (CONF_BUSY_PIN, pins.gpio_input_pin_schema), + (CONF_RESET_PIN, pins.gpio_output_pin_schema), + (CONF_CS_PIN, pins.gpio_output_pin_schema), + ) + pin_extra = {} + for key, schema_value in pin_specs: + opt, sv = _model_pin_option(model, key, schema_value) + pin_extra[opt] = sv + return schema.extend(pin_extra) + + +def _customise_schema(config): + config = cv.Schema( + { + cv.Required(CONF_MODEL): cv.one_of( + *IT8951Model.models, upper=True, space="-" + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + + model_config = _model_schema(config)(config) + + model = IT8951Model.models[config[CONF_MODEL].upper()] + width, height = model.get_dimensions(model_config) + + display.add_metadata( + model_config[CONF_ID], + width, + height, + # Rotation is applied per-pixel in draw_pixel_at at no extra cost, so we + # advertise hardware rotation: LVGL routes its rotation to the driver via + # set_rotation rather than rotating the framebuffer in software. + has_hardware_rotation=True, + has_writer=any( + model_config.get(key) + for key in (CONF_LAMBDA, CONF_PAGES, CONF_SHOW_TEST_CARD) + ), + # Report the configured rotation so LVGL can detect (and reject) a + # rotation set in the display config instead of the LVGL config. + rotation=model_config.get(CONF_ROTATION, 0), + # The IT8951 snaps partial display refreshes to a 32-pixel X boundary + # (see prepare_update_region_), so have LVGL round its redraw areas to + # 32px too — this keeps flush rectangles aligned with what the panel + # actually refreshes and avoids redundant re-rounding/over-draw. + draw_rounding=32, + ) + + return model_config + + +CONFIG_SCHEMA = _customise_schema + + +def _final_validate(config): + # IT8951 reads from SPI (DevInfo, VCOM, register reads) so MISO is required. + spi.final_validate_device_schema("it8951", require_miso=True, require_mosi=True)( + config + ) + + global_config = full_config.get() + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if CONF_LAMBDA not in config and CONF_PAGES not in config: + if LVGL_DOMAIN in global_config: + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("never") + else: + config[CONF_SHOW_TEST_CARD] = True + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config): + model = IT8951Model.models[config[CONF_MODEL]] + width, height = model.get_dimensions(config) + + var = cg.new_Pvariable(config[CONF_ID], model.name, width, height) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=False) + + if lambda_config := config.get(CONF_LAMBDA): + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) + if busy_pin := config.get(CONF_BUSY_PIN): + cg.add(var.set_busy_pin(await cg.gpio_pin_expression(busy_pin))) + if enable_pins := config.get(CONF_ENABLE_PIN): + cg.add( + var.set_enable_pins( + [await cg.gpio_pin_expression(pin) for pin in enable_pins] + ) + ) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) + if (reset_duration := config.get(CONF_RESET_DURATION)) is not None: + cg.add(var.set_reset_duration(reset_duration)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) + if config.get(CONF_SLEEP_WHEN_DONE): + cg.add(var.set_sleep_when_done(True)) + cg.add(var.set_vcom(config[CONF_VCOM])) + cg.add(var.set_vcom_register(config[CONF_VCOM_REGISTER])) + if CONF_FORCE_TEMPERATURE in config: + cg.add(var.set_force_temperature(config[CONF_FORCE_TEMPERATURE])) + if config.get(CONF_USE_LEGACY_DPY_AREA): + cg.add(var.set_use_legacy_dpy_area(True)) + cg.add(var.set_grayscale(config[CONF_GRAYSCALE])) + cg.add(var.set_dithering(config[CONF_DITHERING])) + if (mode := config.get(CONF_UPDATE_MODE)) is not None: + cg.add(var.set_update_mode(mode)) + + transform = config.get( + CONF_TRANSFORM, + { + CONF_MIRROR_X: model.get_default(CONF_MIRROR_X), + CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y), + }, + ) + + transform_value = sum( + flag for key, flag in _TRANSFORM_FLAGS.items() if transform.get(key) + ) + if transform_value: + cg.add(var.set_transform(RawExpression(str(transform_value)))) + + +@automation.register_action( + "it8951.update", + IT8951UpdateAction, + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(IT8951Display), + cv.Optional(CONF_MODE): cv.templatable(update_mode), + } + ), + synchronous=True, +) +async def it8951_update_action_to_code(config, action_id, template_arg, args): + display_var = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, display_var) + if mode := config.get(CONF_MODE): + mode = await cg.templatable(mode, args, UpdateMode) + cg.add(var.set_mode(mode)) + return var diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp new file mode 100644 index 0000000000..cc2bddeda7 --- /dev/null +++ b/esphome/components/it8951/it8951.cpp @@ -0,0 +1,1091 @@ +#include "it8951.h" + +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::it8951 { + +static const char *const TAG = "it8951"; + +// Soft cap for time spent in a single XFER_ROWS Op so we yield back to the +// loop within one tick budget. +static constexpr uint32_t MAX_TRANSFER_TIME_MS = 20; + +// --- Loop / scheduling ------------------------------------------------------- + +void IT8951Display::enqueue_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_back(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +void IT8951Display::prepend_(OpType type, uint16_t a, uint16_t b) { + if (!this->queue_.push_front(Op{type, a, b})) { + ESP_LOGE(TAG, "Op queue overflow (cap=%u); dropping op type=%u", static_cast(OP_QUEUE_SIZE), + static_cast(type)); + } +} + +bool IT8951Display::is_busy_() const { + // IT8951 Hardware Ready (HW_RDY): HIGH = ready, LOW = busy. + return !this->busy_pin_->digital_read(); +} + +void IT8951Display::loop() { + const uint32_t now = millis(); + if (static_cast(now - this->delay_until_) < 0) + return; + + // Nothing queued — either the current phase has more work to enqueue, or + // we're done. + if (this->queue_.empty()) { + if (this->phase_ == Phase::IDLE) { + this->disable_loop(); + return; + } + this->advance_phase_(); + if (this->queue_.empty()) + return; + } + + // Gate SPI ops on HW_RDY. GPIO/DELAY ops run unconditionally — they're how + // we get the controller out of a stuck-busy state in the first place + // (e.g. during reset, HW_RDY is undefined/low until ROM boot completes). + Op queued_op = this->queue_.front(); + const bool needs_hardware_ready = queued_op.type != OpType::GPIO_RESET_LOW && + queued_op.type != OpType::GPIO_RESET_HIGH && queued_op.type != OpType::DELAY_MS; + if (needs_hardware_ready && this->is_busy_()) { + // Signed elapsed: any pending DELAY_MS or scheduled work in the near + // future shows up as <= 0 elapsed and won't trigger a false timeout. + const int32_t elapsed = static_cast(now - this->phase_started_at_); + ESP_LOGV(TAG, "HW_RDY is LOW (busy) in phase %u, elapsed=%" PRId32 "ms", static_cast(this->phase_), + elapsed); + if (elapsed > static_cast(BUSY_TIMEOUT_MS)) { + ESP_LOGW(TAG, "Busy timeout (%" PRIu32 "ms) in phase %u, recovering", elapsed, + static_cast(this->phase_)); + this->recover_(); + } + return; + } + + this->queue_.pop_front(); + this->process_op_(queued_op); +} + +void IT8951Display::process_op_(const Op &op) { + ESP_LOGV(TAG, "Processing op type=%u a=0x%04X b=0x%04X", static_cast(op.type), op.a, op.b); + switch (op.type) { + case OpType::CMD: + this->spi_cmd_(op.a); + break; + case OpType::WRITE_W: + this->spi_write_word_(op.a); + break; + case OpType::WRITE_REG: + this->spi_write_reg_(op.a, op.b); + break; + case OpType::READ_DEV_INFO: + this->spi_read_dev_info_(); + break; + case OpType::READ_WORD: + this->read_result_ = this->spi_read_word_(); + break; + case OpType::CHECK_LUT_IDLE: + this->op_check_lut_idle_(); + break; + case OpType::SET_1BPP: + this->op_set_1bpp_(); + break; + case OpType::XFER_LISAR: + this->op_xfer_lisar_(); + break; + case OpType::XFER_AREA_CMD: + this->spi_cmd_(TCON_LD_IMG_AREA); + break; + case OpType::XFER_AREA_ARGS: + this->op_xfer_area_args_(); + break; + case OpType::XFER_ROWS: + // Stream rows into the single open LD_IMG_AREA load. The load stays open + // across loop iterations (CS toggles between bursts, matching the + // reference driver), so a partial slice just re-queues another XFER_ROWS + // pass to resume; only when all rows are sent do we close it with one + // LD_IMG_END. This avoids an LD_IMG_END / LD_IMG_AREA round-trip per slice. + if (this->op_xfer_rows_()) { + this->enqueue_(OpType::XFER_AREA_END); + } else { + this->enqueue_(OpType::XFER_ROWS); + } + break; + case OpType::XFER_AREA_END: + this->op_xfer_area_end_(); + break; + case OpType::DPY_BUF_CMD: + // Some panel firmwares (notably Seeed reTerminal E1003) silently drop + // I80_CMD_DPY_BUF_AREA (0x0037) — the LUT engine never starts and the + // host eventually times out after ~12s. Fall back to the basic + // I80_CMD_DPY_AREA (0x0034) for those panels; the buffer address is + // already programmed via LISAR during the transfer phase. + this->spi_cmd_(this->use_legacy_dpy_area_ ? I80_CMD_DPY_AREA : I80_CMD_DPY_BUF_AREA); + break; + case OpType::DPY_BUF_ARGS: + this->op_dpy_buf_args_(); + break; + case OpType::GPIO_RESET_LOW: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(false); + break; + case OpType::GPIO_RESET_HIGH: + if (this->reset_pin_ != nullptr) + this->reset_pin_->digital_write(true); + break; + case OpType::DELAY_MS: + this->delay_until_ = millis() + op.a; + break; + } +} + +void IT8951Display::set_phase_(Phase next) { + ESP_LOGV(TAG, "Phase %u -> %u", static_cast(this->phase_), static_cast(next)); + // Run the loop continuously for the whole active sequence, returning to normal + // throttling only at IDLE. Each queued op is processed one per loop iteration, + // so at the default ~16ms loop interval the dozens of small ops in the refresh + // and restore phases (register polls, 1bpp enable/restore, DPY) would dominate + // a partial update's latency. The LUT-idle polls are DELAY_MS-paced, so this + // doesn't hammer SPI — it only spends a little extra CPU during the (short, + // infrequent) update instead of sleeping between ops. start()/stop() are + // idempotent, so driving them off the transition is safe. + if (next == Phase::IDLE) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + } + this->phase_ = next; + this->phase_started_at_ = millis(); +} + +void IT8951Display::advance_phase_() { + switch (this->phase_) { + case Phase::IDLE: + if (this->initialised_ && this->update_pending_) { + this->update_pending_ = false; + this->active_mode_ = this->pending_update_mode_; + this->update_started_at_ = millis(); + this->set_phase_(Phase::UPDATE_PREPARE); + this->advance_phase_(); + } else { + this->disable_loop(); + } + break; + + case Phase::INIT_RESET: + this->set_phase_(Phase::INIT_DEV_INFO); + this->enqueue_init_dev_info_(); + break; + + case Phase::INIT_DEV_INFO: + if (this->dev_info_.panel_width == 0 || this->dev_info_.panel_width > 2048 || this->dev_info_.panel_height == 0 || + this->dev_info_.panel_height > 2048 || this->dev_info_.panel_width == 0xFFFF || + this->dev_info_.panel_height == 0xFFFF) { + if (++this->dev_info_attempts_ < 5) { + ESP_LOGW(TAG, "DevInfo attempt %u returned invalid data (W=%u H=%u), retrying...", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + // Give the controller more time, then re-read. + this->enqueue_(OpType::DELAY_MS, 100); + this->enqueue_init_dev_info_(); + return; + } + ESP_LOGE(TAG, "DevInfo invalid after %u attempts (W=%u H=%u)", this->dev_info_attempts_, + this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("Failed to read IT8951 device info")); + this->set_phase_(Phase::IDLE); + return; + } + + if (this->dev_info_.panel_width != this->width_ || this->dev_info_.panel_height != this->height_) { + ESP_LOGE(TAG, "Panel dimension mismatch: configured=%ux%u, DevInfo=%ux%u. Check model/dimensions settings.", + this->width_, this->height_, this->dev_info_.panel_width, this->dev_info_.panel_height); + this->mark_failed(LOG_STR("IT8951 panel dimensions do not match DevInfo")); + this->set_phase_(Phase::IDLE); + return; + } + + this->dev_info_attempts_ = 0; + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + this->img_buf_addr_l_ = this->dev_info_.img_buf_addr_l; + this->img_buf_addr_h_ = this->dev_info_.img_buf_addr_h; + ESP_LOGI(TAG, "DevInfo: %ux%u, ImgBuf 0x%04X%04X", this->width_, this->height_, this->img_buf_addr_h_, + this->img_buf_addr_l_); + this->set_phase_(Phase::INIT_VCOM); + this->enqueue_init_vcom_(); + break; + + case Phase::INIT_VCOM: + this->set_phase_(Phase::INIT_TEMP); + if (this->force_temperature_set_) { + this->enqueue_init_temp_(); + } else { + this->advance_phase_(); + } + break; + + case Phase::INIT_TEMP: + this->set_phase_(Phase::INIT_DONE); + this->advance_phase_(); + break; + + case Phase::INIT_DONE: + if (this->configured_data_rate_ != 0 && this->configured_data_rate_ != this->data_rate_) { + this->spi_teardown(); + this->set_data_rate(this->configured_data_rate_); + this->spi_setup(); + } + this->initialised_ = true; + this->recovery_attempts_ = 0; + ESP_LOGCONFIG(TAG, "IT8951 setup complete"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + + case Phase::UPDATE_PREPARE: { + this->do_update_(); + UpdateMode mode = this->active_mode_; + if (!this->prepare_update_region_(mode)) { + ESP_LOGD(TAG, "Nothing to update"); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + return; + } + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_TRANSFER); + this->enqueue_update_transfer_(); + break; + } + + case Phase::UPDATE_TRANSFER: + this->set_phase_(Phase::UPDATE_REFRESH); + this->enqueue_update_refresh_(); + break; + + case Phase::UPDATE_REFRESH: + // Fire-and-forget: don't block here waiting for the refresh to complete. + // The next update's pre-display LUT-idle poll (and the HW_RDY-gated + // TCON_SLEEP) wait as needed, so the refresh time stays off this update's + // critical path. The 1bpp display mode is left enabled rather than + // restored after every update: on a monochrome display every update + // (DU partials and the periodic GC16 cleans) runs in 1bpp mode, so the + // bit never needs clearing — and clearing it required a full + // refresh-length LUT-idle wait. + this->set_phase_(Phase::UPDATE_SLEEP); + this->enqueue_update_sleep_(); + break; + + case Phase::UPDATE_SLEEP: + ESP_LOGV(TAG, "Update took %" PRIu32 "ms (mode=%u area=%ux%u@%u,%u)", millis() - this->update_started_at_, + static_cast(this->active_mode_), this->area_w_, this->area_h_, this->area_x_, this->area_y_); + this->set_phase_(Phase::IDLE); + this->advance_phase_(); + break; + } +} + +// --- Setup ------------------------------------------------------------------- + +void IT8951Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up IT8951..."); + this->configured_data_rate_ = this->data_rate_; + this->data_rate_ = SPI_PROBE_FREQUENCY; + this->spi_setup(); + + // Power on the panel before reset and the init handshake. + for (auto *pin : this->enable_pins_) { + pin->setup(); + pin->digital_write(true); + } + + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + if (this->busy_pin_ != nullptr) { + this->busy_pin_->setup(); + } + + this->update_effective_transform_(); + this->reset_dirty_region_(); + + // Allocate the framebuffer now: its size is fixed by the configured pixel + // format and dimensions, so there's no need to defer to the async controller + // init. LVGL (and other writers) can push pixels via draw_pixels_at as soon + // as the component is set up — before init completes — and without a buffer + // those writes would dereference a null pointer and crash. + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(this->height_); + RAMAllocator allocator{}; + this->buffer_ = allocator.allocate(this->buffer_length_); + if (this->buffer_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate IT8951 framebuffer")); + return; + } + // The allocator does not zero memory; start blank (white) so undrawn regions + // (e.g. with auto_clear disabled) don't show garbage on the first update. + this->fill(Color::WHITE); + + // Kick off async init via the queue. Reset pulse + boot delay + wake + + // packed-write enable; everything blocking lives as DELAY_MS Ops gated by + // the loop scheduler. + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->enable_loop(); +} + +void IT8951Display::on_safe_shutdown() { + // Best-effort synchronous sleep — runs during shutdown so we don't queue. + this->spi_cmd_(TCON_SLEEP); +} + +// --- Init op enqueuers ------------------------------------------------------- + +void IT8951Display::enqueue_init_reset_() { + // A reset (including recovery) re-runs SYS_RUN below, so the controller is + // awake once this sequence completes. + this->asleep_ = false; + // Reset pulse: high -> low (reset_duration) -> high -> wait for ROM boot. + this->enqueue_(OpType::GPIO_RESET_HIGH); + this->enqueue_(OpType::GPIO_RESET_LOW); + this->enqueue_(OpType::DELAY_MS, static_cast(this->reset_duration_)); + this->enqueue_(OpType::GPIO_RESET_HIGH); + // SPI ROM boot. HW_RDY gating in loop() handles the actual wait, but a small + // floor avoids hammering SPI before HW_RDY has settled high. 300ms matches + // what most IT8951 reference drivers use for safety. + this->enqueue_(OpType::DELAY_MS, 300); + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->enqueue_(OpType::CMD, TCON_REG_WR); // packed write mode + this->enqueue_(OpType::WRITE_REG, I80CPCR, 0x0001); +} + +void IT8951Display::enqueue_init_dev_info_() { + // CMD triggers the controller to prepare DevInfo. HW_RDY drops while it works. + // The loop-level HW_RDY gate non-blockingly waits before dispatching READ_DEV_INFO. + this->enqueue_(OpType::CMD, I80_CMD_GET_DEV_INFO); + this->enqueue_(OpType::READ_DEV_INFO); +} + +void IT8951Display::enqueue_init_vcom_() { + // Always write configured VCOM. The IT8951 stores it in OTP-backed RAM; + // rewriting the same value is harmless. The VCOM SET selector is + // panel-specific (see I80_CMD_VCOM_WRITE / I80_CMD_VCOM_WRITE_ALT in + // it8951_defs.h) and is supplied via the model preset. + this->enqueue_(OpType::CMD, I80_CMD_VCOM); + this->enqueue_(OpType::WRITE_W, this->vcom_register_); + this->enqueue_(OpType::WRITE_W, this->vcom_); +} + +void IT8951Display::enqueue_init_temp_() { + // Force panel temperature (in degrees C) so the controller selects the + // correct waveform LUT. Some panels (e.g. Seeed reTerminal E1003) ship + // with auto-temperature disabled and rely on the host to declare the + // operating temperature; without this, grayscale waveforms run against + // a mismatched LUT and pixels do not visibly change even though the LUT + // engine completes a full cycle. + this->enqueue_(OpType::CMD, I80_CMD_FORCE_TEMP); + this->enqueue_(OpType::WRITE_W, I80_CMD_FORCE_TEMP_WRITE); + this->enqueue_(OpType::WRITE_W, static_cast(this->force_temperature_)); +} + +// --- Update op enqueuers ----------------------------------------------------- + +void IT8951Display::enqueue_update_transfer_() { + // If the controller was put to sleep after the previous update, wake it + // before touching the display engine. TCON_SLEEP gates off all clocks; a + // register read (e.g. the LUTAFSR poll in UPDATE_REFRESH) returns a frozen + // value while asleep, so without this the next update stalls forever in + // op_check_lut_idle_(). SRAM/registers (packed-write mode, VCOM, LUT) are + // retained across sleep, so SYS_RUN + a short settle is all that's needed. + if (this->asleep_) { + this->enqueue_(OpType::CMD, TCON_SYS_RUN); + this->enqueue_(OpType::DELAY_MS, 10); // clocks settle after SYS_RUN + this->asleep_ = false; + } + this->transfer_row_ = 0; + // Open a single LD_IMG_AREA load for the whole region. XFER_ROWS streams into + // it across as many time-sliced passes as needed and emits the one matching + // LD_IMG_END when the last row is sent (see the XFER_ROWS handler). + this->enqueue_(OpType::XFER_LISAR); + this->enqueue_(OpType::XFER_AREA_CMD); + this->enqueue_(OpType::XFER_AREA_ARGS); + this->enqueue_(OpType::XFER_ROWS); +} + +void IT8951Display::enqueue_update_refresh_() { + ESP_LOGV(TAG, "Enqueueing refresh ops: grayscale=%u", this->grayscale_); + // Poll LUT idle: CMD(REG_RD) → WRITE_W(LUTAFSR) → READ_WORD → CHECK_LUT_IDLE + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, LUTAFSR); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::CHECK_LUT_IDLE); + if (!this->grayscale_) { + // Read UP1SR+2: CMD(REG_RD) → WRITE_W(UP1SR+2) → READ_WORD → SET_1BPP + this->enqueue_(OpType::CMD, TCON_REG_RD); + this->enqueue_(OpType::WRITE_W, static_cast(UP1SR + 2)); + this->enqueue_(OpType::READ_WORD); + this->enqueue_(OpType::SET_1BPP); + } + this->enqueue_(OpType::DPY_BUF_CMD); + this->enqueue_(OpType::DPY_BUF_ARGS); +} + +void IT8951Display::enqueue_update_sleep_() { + if (this->sleep_when_done_) { + this->enqueue_(OpType::CMD, TCON_SLEEP); + // Remember that the controller is now asleep so the next update wakes it + // (see enqueue_update_transfer_) before polling any register. + this->asleep_ = true; + } +} + +// --- SPI primitives ---------------------------------------------------------- +// +// IT8951 SPI protocol: no DC pin. 16-bit preamble word identifies whether +// the transaction is command (0x6000), write-data (0x0000), or read-data +// (0x1000). +// +// All ops are fully non-blocking at the loop level. The loop-level HW_RDY gate +// guarantees the controller is ready before any op is dispatched. +// +// Within a single CS-asserted transaction, the IT8951 requires HW_RDY to be +// checked after the preamble word before sending the first data word. This +// is a hardware protocol requirement — the controller needs a few clock +// cycles to latch the preamble and configure its internal bus direction. +// In practice this completes in <1µs for write ops; we use a short spin +// (max ~50µs) that never triggers under normal operation. + +static constexpr uint32_t INTRA_CS_READY_TIMEOUT_US = 50; + +static inline void wait_for_hardware_ready(GPIOPin *busy_pin) { + if (busy_pin == nullptr) + return; + uint32_t waited = 0; + while (!busy_pin->digital_read()) { + if (waited >= INTRA_CS_READY_TIMEOUT_US) + return; + delayMicroseconds(1); + waited += 1; + } +} + +void IT8951Display::spi_cmd_(uint16_t cmd) { + this->enable(); + this->write_byte16(PACKET_TYPE_CMD); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(cmd); + this->disable(); +} + +void IT8951Display::spi_write_word_(uint16_t value) { + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_reg_(uint16_t addr, uint16_t value) { + // Single CS transaction: WRITE preamble + addr + value. + // Caller must have already sent CMD(TCON_REG_WR) as a prior op. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(addr); + this->write_byte16(value); + this->disable(); +} + +void IT8951Display::spi_write_args_(const uint16_t *args, uint16_t count) { + // Single CS transaction: WRITE preamble + N data words. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + for (uint16_t i = 0; i < count; i++) + this->write_byte16(args[i]); + this->disable(); +} + +uint16_t IT8951Display::spi_read_word_() { + // Single CS read transaction. HW_RDY was confirmed HIGH by the loop gate + // before this op was dispatched, so data is ready. + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy — provides clock cycles for controller + wait_for_hardware_ready(this->busy_pin_); + // Read byte-by-byte: a 2-byte transfer_array can lose the low byte on + // ESP-IDF SPI DMA due to 4-byte alignment requirements. + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + this->disable(); + return encode_uint16(hi, lo); +} + +void IT8951Display::spi_read_dev_info_() { + // Read DevInfo struct. The CMD(GET_DEV_INFO) was already sent as a prior op, + // and the loop HW_RDY gate waited for the controller to prepare data. + std::memset(&this->dev_info_, 0, sizeof(this->dev_info_)); + this->enable(); + this->write_byte16(PACKET_TYPE_READ); + wait_for_hardware_ready(this->busy_pin_); + this->write_byte16(0x0000); // dummy + wait_for_hardware_ready(this->busy_pin_); + auto *words = reinterpret_cast(&this->dev_info_); + constexpr uint32_t word_count = sizeof(this->dev_info_) / sizeof(uint16_t); + for (uint32_t i = 0; i < word_count; i++) { + const uint8_t hi = this->transfer_byte(0); + const uint8_t lo = this->transfer_byte(0); + words[i] = encode_uint16(hi, lo); + } + this->disable(); +} + +// --- Compound Ops ------------------------------------------------------------ + +void IT8951Display::op_xfer_lisar_() { + // Set image-buffer target address. Two register writes = 4 CS transactions. + // Push to FRONT in reverse order so they execute before the rest of the queue. + this->prepend_(OpType::WRITE_REG, LISAR, this->img_buf_addr_l_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, static_cast(LISAR + 2), this->img_buf_addr_h_); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +void IT8951Display::op_xfer_area_args_() { + // Single CS transaction: WRITE preamble + 5 area-parameter words describing + // the full update region. Sent once when the load is opened (transfer_row_ is + // 0); XFER_ROWS then streams every row into this one area. + uint16_t args[5]; + if (this->grayscale_) { + args[0] = static_cast((LDIMG_B_ENDIAN << 8) | (PIXEL_4BPP << 4)); + args[1] = this->area_x_; + args[2] = this->area_y_; + args[3] = this->area_w_; + args[4] = this->area_h_; + } else { + // Monochrome is loaded via the 8bpp-packed trick: x and width are expressed + // in bytes (8 pixels each) and the controller unpacks one bit per pixel. + args[0] = static_cast((LDIMG_L_ENDIAN << 8) | (PIXEL_8BPP << 4)); + args[1] = static_cast(this->area_x_ / 8); + args[2] = this->area_y_; + args[3] = static_cast(this->area_w_ / 8); + args[4] = this->area_h_; + } + this->spi_write_args_(args, 5); +} + +void IT8951Display::op_xfer_area_end_() { this->spi_cmd_(TCON_LD_IMG_END); } + +bool IT8951Display::op_xfer_rows_() { + const uint32_t start_time = millis(); + const uint16_t area_y = this->area_y_; + const uint16_t area_h = this->area_h_; + + // Bytes per source row, and the byte offset of area_x within a row, in the + // framebuffer's native packing. These match the per-row byte count the + // controller expects from op_xfer_area_args_: area_w/2 for 4bpp grayscale, + // area_w/8 for the 1bpp-packed monochrome trick. area_x / area_w are + // 16-pixel aligned (see prepare_update_region_), so both divisions are exact. + const uint16_t bytes_per_row = + this->grayscale_ ? static_cast(this->area_w_ >> 1) : static_cast(this->area_w_ >> 3); + const uint16_t row_x_bytes = + this->grayscale_ ? static_cast(this->area_x_ >> 1) : static_cast(this->area_x_ >> 3); + + // Single CS write transaction — HW_RDY was confirmed high by the loop gate. + this->enable(); + this->write_byte16(PACKET_TYPE_WRITE); + wait_for_hardware_ready(this->busy_pin_); + + // Each source row is a contiguous slice of the framebuffer in both formats — + // the buffer already holds the wire bytes — so stream it straight to SPI with + // no per-pixel packing or temporary buffer. + while (this->transfer_row_ < area_h) { + const uint32_t offset = (static_cast(area_y) + this->transfer_row_) * this->row_width_ + row_x_bytes; + this->write_array(&this->buffer_[offset], bytes_per_row); + this->transfer_row_++; + if (millis() - start_time >= MAX_TRANSFER_TIME_MS) + break; + } + + this->disable(); + return this->transfer_row_ >= area_h; +} + +void IT8951Display::op_dpy_buf_args_() { + // I80_CMD_DPY_BUF_AREA (0x0037) takes 7 args (with explicit buffer addr). + // I80_CMD_DPY_AREA (0x0034) takes 5 args; the buffer address is taken + // from LISAR which we program during the transfer phase, so this is safe. + if (this->use_legacy_dpy_area_) { + const uint16_t args[5] = { + this->area_x_, this->area_y_, this->area_w_, this->area_h_, static_cast(this->active_mode_), + }; + this->spi_write_args_(args, 5); + return; + } + const uint16_t args[7] = { + this->area_x_, + this->area_y_, + this->area_w_, + this->area_h_, + static_cast(this->active_mode_), + this->img_buf_addr_l_, + this->img_buf_addr_h_, + }; + this->spi_write_args_(args, 7); +} + +void IT8951Display::op_check_lut_idle_() { + ESP_LOGV(TAG, "Checking LUT idle, read_result_=0x%04X", this->read_result_); + // read_result_ holds LUTAFSR value from the preceding READ_WORD op. + if (this->read_result_ != 0) { + // LUT still busy — re-enqueue the full read sequence after a short delay. + this->prepend_(OpType::CHECK_LUT_IDLE, 0, 0); + this->prepend_(OpType::READ_WORD, 0, 0); + this->prepend_(OpType::WRITE_W, LUTAFSR, 0); + this->prepend_(OpType::CMD, TCON_REG_RD, 0); + this->prepend_(OpType::DELAY_MS, 5, 0); + } +} + +void IT8951Display::op_set_1bpp_() { + // read_result_ holds UP1SR+2 value. Set bit 2 and write back, then set BGVR. + // Push to FRONT in reverse order so they execute before DPY_BUF_CMD/ARGS + // that are already in the queue. + const uint16_t modified = static_cast(this->read_result_ | (1U << 2)); + this->prepend_(OpType::WRITE_REG, BGVR, 0xFF00); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); + this->prepend_(OpType::WRITE_REG, UP1SR + 2, modified); + this->prepend_(OpType::CMD, TCON_REG_WR, 0); +} + +// --- Update prep / public API ------------------------------------------------ + +bool IT8951Display::prepare_update_region_(UpdateMode &mode) { + this->partial_update_count_++; + const bool full_update = this->partial_update_count_ >= this->full_update_every_; + if (full_update) { + this->partial_update_count_ = 0; + mode = UPDATE_MODE_GC16; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } else { + // Align the partial region's X extent to 32 pixels. The IT8951's partial + // display refresh snaps the X start/width to a 32-pixel boundary (the panel + // source driver fetches 32-pixel chunks); refreshing a region whose X is + // only 16-aligned makes the panel snap it down to the previous boundary, + // shifting that update ~16px to the left. 32-alignment also satisfies the + // load constraints (4bpp X must be a multiple of 4; the 8bpp-packed mono + // load needs x/8 even, i.e. X a multiple of 16). + this->x_low_ &= 0xFFE0; + uint16_t temp_max = this->x_high_ > 0 ? static_cast(this->x_high_ - 1) : 0; + temp_max = static_cast(temp_max | 0x001F); + if (temp_max >= this->width_) + temp_max = static_cast(this->width_ - 1); + this->x_high_ = static_cast(temp_max + 1); + } + + if (this->x_high_ <= this->x_low_ || this->y_high_ <= this->y_low_) { + this->reset_dirty_region_(); + return false; + } + + const uint16_t x = this->x_low_; + const uint16_t y = this->y_low_; + const uint16_t width = static_cast(this->x_high_ - this->x_low_); + const uint16_t height = static_cast(this->y_high_ - this->y_low_); + + if (x >= this->width_ || y >= this->height_ || (x + width) > this->width_ || (y + height) > this->height_) { + ESP_LOGE(TAG, "Dirty region (%u,%u %ux%u) out of bounds", x, y, width, height); + this->reset_dirty_region_(); + return false; + } + + this->area_x_ = x; + this->area_y_ = y; + this->area_w_ = width; + this->area_h_ = height; + this->transfer_row_ = 0; + + // On non-full updates, downgrade monochrome frames from the full, flashy GC16 + // clear to DU — a fast, low-flash absolute waveform — so full_update_every + // buys cheaper refreshes between the periodic GC16 cleans that clear + // accumulated ghosting. + // + // Grayscale frames are deliberately left on GC16: every reduced grayscale + // waveform this controller exposes (the non-flashing GL family GL16/GLR16/ + // GLD16, and the 4-tone DU4) renders incorrectly on the supported panels — + // a white background is driven to grey rather than staying white. GC16 is the + // only waveform that reproduces grayscale faithfully, so we keep it. + // + // An explicitly configured non-GC16 update_mode is honoured as-is. + if (!full_update && mode == UPDATE_MODE_GC16 && !this->grayscale_) + mode = UPDATE_MODE_DU; + + this->reset_dirty_region_(); + + ESP_LOGV(TAG, "Update: %ux%u@%u,%u mode=%u (%s)", width, height, x, y, static_cast(mode), + this->grayscale_ ? "grayscale" : "mono"); + return true; +} + +void IT8951Display::reset_dirty_region_() { + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; +} + +void IT8951Display::start_update_(UpdateMode mode) { + if (this->phase_ == Phase::IDLE && this->initialised_) { + this->update_started_at_ = millis(); + this->active_mode_ = mode; + this->set_phase_(Phase::UPDATE_PREPARE); + this->enable_loop(); + this->advance_phase_(); + } else { + // Coalesce: latest pending mode wins. + this->update_pending_ = true; + this->pending_update_mode_ = mode; + this->enable_loop(); + } +} + +void IT8951Display::update() { + if (!this->is_ready()) + return; + if (this->default_update_mode_ != UPDATE_MODE_NONE) { + this->start_update_(this->default_update_mode_); + return; + } + this->start_update_(UPDATE_MODE_GC16); +} + +void IT8951Display::update_mode(UpdateMode mode) { + if (!this->is_ready()) + return; + if (mode == UPDATE_MODE_NONE) { + ESP_LOGW(TAG, "Unknown update mode"); + return; + } + this->start_update_(mode); +} + +// --- Recovery ---------------------------------------------------------------- + +void IT8951Display::recover_() { + if (++this->recovery_attempts_ > 3) { + ESP_LOGE(TAG, "Recovery failed after %u attempts; giving up. Check BUSY pin wiring and power.", + this->recovery_attempts_); + this->mark_failed(LOG_STR("IT8951 recovery exhausted")); + this->queue_.clear(); + this->set_phase_(Phase::IDLE); + this->disable_loop(); + return; + } + ESP_LOGW(TAG, "Recovering (attempt %u): hardware-resetting controller (was in phase %u)", this->recovery_attempts_, + static_cast(this->phase_)); + this->queue_.clear(); + this->update_pending_ = false; + this->transfer_row_ = 0; + this->initialised_ = false; + this->dev_info_attempts_ = 0; + + // Drop SPI clock back to the safe probe rate for the re-init handshake. + if (this->configured_data_rate_ != 0 && this->data_rate_ != SPI_PROBE_FREQUENCY) { + this->spi_teardown(); + this->set_data_rate(SPI_PROBE_FREQUENCY); + this->spi_setup(); + } + + // Force a full redraw on next opportunity. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + + this->set_phase_(Phase::INIT_RESET); + this->enqueue_init_reset_(); + this->update_pending_ = true; + this->pending_update_mode_ = UPDATE_MODE_GC16; + this->enable_loop(); +} + +// --- Coordinate transform ---------------------------------------------------- + +void IT8951Display::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_MIRROR_Y | TRANSFORM_MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (TRANSFORM_SWAP_XY | TRANSFORM_MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + +void IT8951Display::apply_transform_(int &x, int &y) const { + if (this->effective_transform_ & TRANSFORM_SWAP_XY) + std::swap(x, y); + if (this->effective_transform_ & TRANSFORM_MIRROR_X) + x = this->width_ - x - 1; + if (this->effective_transform_ & TRANSFORM_MIRROR_Y) + y = this->height_ - y - 1; +} + +bool IT8951Display::rotate_coordinates_(int &x, int &y) { + if (!this->get_clipping().inside(x, y)) + return false; + this->apply_transform_(x, y); + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); + return true; +} + +// --- Color / drawing --------------------------------------------------------- + +static uint8_t quantize_8bit_to_nibble(uint8_t value) { + uint8_t nibble = static_cast((static_cast(value) + 8) >> 4); + return nibble > 0x0F ? 0x0F : nibble; +} + +static uint8_t color_to_nibble(const Color &color) { + // Grayscale images are emitted as Color(gray, gray, gray, 0xFF). + // Handle this shape first so endpoint values don't alias COLOR_ON/OFF. + if (color.w == 0xFF && color.r == color.g && color.g == color.b) + return quantize_8bit_to_nibble(color.r); + + if (color.raw_32 == 0) + return 0x00; // black + if (color.raw_32 == 0xFFFFFFFF) + return 0x0F; // white + + // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by + // 256). Rec.601 is the standard for converting SDR images to grayscale and + // spreads saturated colours across the mid-range; Rec.709 instead crams them + // against white/black where the 16 panel levels are hard to tell apart. + auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); + return quantize_8bit_to_nibble(luma); +} + +// 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). +// A pixel whose luma is below the threshold renders black, so lighter pixels +// produce progressively sparser black dots instead of vanishing to white. The +// matrix averages to 32768, matching the conventional monochrome cut, while the +// per-pixel variation reproduces intermediate gray levels. +static uint16_t dither_threshold(uint16_t x, uint16_t y) { + static const uint8_t BAYER4[16] = {0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5}; + return static_cast(BAYER4[((y & 3) << 2) | (x & 3)] * 4096u + 2048u); +} + +void IT8951Display::fill(Color color) { + if (this->buffer_ == nullptr) + return; + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + uint8_t packed = color_to_nibble(color); + if (this->invert_colors_) + packed = 0x0F - packed; + uint8_t fill_byte; + if (this->grayscale_) { + fill_byte = static_cast((packed << 4) | packed); + } else { + fill_byte = (packed <= 0x07) ? 0xFF : 0x00; + } + memset(this->buffer_, fill_byte, this->buffer_length_); + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { + if (this->buffer_ == nullptr) + return; + App.feed_wdt(); + if (!this->rotate_coordinates_(x, y)) + return; + this->write_pixel_native_(static_cast(x), static_cast(y), color); +} + +void HOT IT8951Display::write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const { + if (this->grayscale_) { + uint8_t nibble = color_to_nibble(color); + if (this->invert_colors_) + nibble = static_cast(0x0F - nibble); + this->set_gray_pixel_(x, y, nibble); + } else { + // Rec.601 luma (see color_to_nibble). Weights sum to 257 so white maps to + // exactly 65535, using the full 16-bit range without overflow. + auto lum = static_cast(77u * color.r + 151u * color.g + 29u * color.b); + if (this->invert_colors_) + lum = static_cast(65535u - lum); + // Set the bit (foreground/black) when this pixel is darker than its + // threshold. With dithering the threshold varies per pixel so pale colours + // render as visible texture; otherwise it's the fixed ~50% cut (r+g+b<32768). + const uint16_t threshold = this->dithering_ ? dither_threshold(x, y) : 32768; + this->set_mono_pixel_(x, y, lum < threshold); + } +} + +void HOT IT8951Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // A writer (e.g. LVGL) may push pixels before the framebuffer is ready or + // after an allocation failure; ignore those rather than dereferencing null. + if (this->buffer_ == nullptr) + return; + // A clipping rectangle would need a per-pixel test; that's rare for the bulk + // blit callers (LVGL, images), so fall back to the base per-pixel path then. + if (this->get_clipping().is_set()) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + + const size_t line_stride = static_cast(x_offset) + w + x_pad; // source line length in pixels + for (int y = 0; y < h; y++) { + App.feed_wdt(); + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x < w; x++, source_idx++) { + uint32_t color_value; + switch (bitness) { + case COLOR_BITNESS_565: { + const size_t i = source_idx * 2; + color_value = big_endian ? (static_cast(ptr[i]) << 8) | ptr[i + 1] + : ptr[i] | (static_cast(ptr[i + 1]) << 8); + break; + } + case COLOR_BITNESS_888: { + const size_t i = source_idx * 3; + color_value = + big_endian + ? (static_cast(ptr[i]) << 16) | (static_cast(ptr[i + 1]) << 8) | ptr[i + 2] + : ptr[i] | (static_cast(ptr[i + 1]) << 8) | (static_cast(ptr[i + 2]) << 16); + break; + } + default: + color_value = ptr[source_idx]; + break; + } + int nx = x_start + x; + int ny = y_start + y; + this->apply_transform_(nx, ny); + if (nx < 0 || ny < 0 || nx >= this->width_ || ny >= this->height_) + continue; + this->write_pixel_native_(static_cast(nx), static_cast(ny), + ColorUtil::to_color(color_value, order, bitness)); + } + } + + // Expand the dirty bounding box once from the transformed block corners: the + // image of an axis-aligned rectangle under swap/mirror is still axis-aligned, + // so its two opposite corners bound it. + int x0 = x_start, y0 = y_start; + int x1 = x_start + w - 1, y1 = y_start + h - 1; + this->apply_transform_(x0, y0); + this->apply_transform_(x1, y1); + const int nx_lo = std::max(0, std::min(x0, x1)); + const int ny_lo = std::max(0, std::min(y0, y1)); + const int nx_hi = std::min(this->width_ - 1, std::max(x0, x1)); + const int ny_hi = std::min(this->height_ - 1, std::max(y0, y1)); + if (nx_hi >= nx_lo && ny_hi >= ny_lo) { + this->x_low_ = clamp_at_most(this->x_low_, nx_lo); + this->x_high_ = clamp_at_least(this->x_high_, nx_hi + 1); + this->y_low_ = clamp_at_most(this->y_low_, ny_lo); + this->y_high_ = clamp_at_least(this->y_high_, ny_hi + 1); + } +} + +void IT8951Display::set_mono_pixel_(uint16_t x, uint16_t y, bool value) const { + // The monochrome framebuffer holds the exact bytes streamed to the + // controller for the 8bpp-load / 1bpp-display trick (L_ENDIAN). Pixels are + // grouped in 16s; on the wire the high byte (pixels 8..15) precedes the low + // byte (pixels 0..7), and the bit index within a byte is the pixel's offset + // (LSB = lowest x). Storing in that order lets op_xfer_rows_ copy rows + // verbatim with no packing or byte-swapping. + const uint16_t group = static_cast(x >> 4); + const uint8_t sub = static_cast(x & 0x0F); + const uint16_t byte_index = static_cast(group * 2u + (sub < 8u ? 1u : 0u)); + const uint8_t mask = static_cast(1u << (sub & 0x07)); + const uint32_t index = static_cast(y) * this->row_width_ + byte_index; + if (value) { + this->buffer_[index] |= mask; + } else { + this->buffer_[index] &= static_cast(~mask); + } +} + +void IT8951Display::set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const { + const uint32_t index = static_cast(y) * this->row_width_ + (static_cast(x) >> 1); + uint8_t buf = this->buffer_[index]; + if (x & 0x1) { + buf = (buf & 0xF0) | nibble; + } else { + buf = (buf & 0x0F) | static_cast(nibble << 4); + } + this->buffer_[index] = buf; +} + +// --- Diagnostics ------------------------------------------------------------- + +void IT8951Display::dump_config() { + LOG_DISPLAY("", "IT8951 E-Paper", this); + char force_temperature[24]; + if (this->force_temperature_set_) { + snprintf(force_temperature, sizeof(force_temperature), "%d °C", this->force_temperature_); + } else { + strncpy(force_temperature, "(controller default)", sizeof(force_temperature)); + force_temperature[sizeof(force_temperature) - 1] = '\0'; + } + ESP_LOGCONFIG(TAG, + " Model preset: %s" + "\n Dimensions: %dx%d" + "\n Buffer: %u bytes" + "\n Image buffer addr: 0x%04X%04X" + "\n VCOM: %.02fV (set selector 0x%04X)" + "\n Force temperature: %s" + "\n Display command: %s" + "\n Sleep when done: %s" + "\n Full update every: %u" + "\n Inverted colors: %s" + "\n Pixel format: %s" + "\n Reset duration: %" PRIu32 "ms", + this->name_ != nullptr ? this->name_ : "(unknown)", this->get_width_internal(), + this->get_height_internal(), static_cast(this->buffer_length_), this->img_buf_addr_h_, + this->img_buf_addr_l_, static_cast(this->vcom_) / 1000.0f, this->vcom_register_, + force_temperature, this->use_legacy_dpy_area_ ? "DPY_AREA (0x0034, legacy)" : "DPY_BUF_AREA (0x0037)", + YESNO(this->sleep_when_done_), this->full_update_every_, YESNO(this->invert_colors_), + this->grayscale_ ? "4bpp grayscale" : "1bpp monochrome", this->reset_duration_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h new file mode 100644 index 0000000000..a5ed03e8c4 --- /dev/null +++ b/esphome/components/it8951/it8951.h @@ -0,0 +1,373 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "it8951_defs.h" + +namespace esphome::it8951 { + +using namespace display; + +// --- Bounded op queue -------------------------------------------------------- +// Fixed-capacity ring buffer used by the loop scheduler. Replaces std::deque +// to comply with ESPHome's STL container guidelines (std::deque allocates in +// 512-byte blocks regardless of element size). Size analysis: the deepest +// observed scenario is UPDATE_REFRESH (10 enqueued ops) + CHECK_LUT_IDLE's +// 5 push_front rescheduling = 14 simultaneous entries. We use 32 for a +// comfortable margin while keeping RAM cost low (~192 bytes per instance vs +// 512+ bytes for std::deque). +template class StaticOpQueue { + public: + bool empty() const { return this->count_ == 0; } + size_t size() const { return this->count_; } + static constexpr size_t capacity() { return N; } + + bool push_back(const T &value) { + if (this->count_ >= N) + return false; + this->data_[(this->head_ + this->count_) % N] = value; + ++this->count_; + return true; + } + + bool push_front(const T &value) { + if (this->count_ >= N) + return false; + this->head_ = (this->head_ + N - 1) % N; + this->data_[this->head_] = value; + ++this->count_; + return true; + } + + void pop_front() { + if (this->count_ == 0) + return; + this->head_ = (this->head_ + 1) % N; + --this->count_; + } + + const T &front() const { return this->data_[this->head_]; } + T &front() { return this->data_[this->head_]; } + + void clear() { + this->head_ = 0; + this->count_ = 0; + } + + private: + T data_[N]{}; + size_t head_{0}; + size_t count_{0}; +}; + +// Op queue capacity. See StaticOpQueue comment for sizing analysis. +static constexpr size_t OP_QUEUE_SIZE = 32; + +// --- Op queue --------------------------------------------------------------- +// Each Op is a single CS-asserted SPI transaction (or a tiny bookkeeping +// step). The loop processes one Op per iteration after gating on HW_RDY, so +// the natural ESPHome loop cadence (~8-16 ms) provides inter-op pacing +// without any blocking waits. +// +// Compound Ops (READ_DEV_INFO, XFER_*, DPY_BUF_AREA, ENABLE_1BPP, ...) are +// short self-contained methods that do all their SPI work inside a single +// CS cycle (or a small handful of cycles) and complete well under 2ms, so +// they don't break the no-blocking budget. +// +// Each write-type op is a SINGLE CS-asserted transaction. The loop-level +// HW_RDY gate ensures the controller is ready before dispatching any op, so +// no blocking waits are needed within write ops. +// +// Read ops are decomposed: the command/address that triggers data preparation +// is sent as write ops (CMD, WRITE_W), then a separate read op runs only +// after the loop confirms HW_RDY is back HIGH (data ready). No blocking. +enum class OpType : uint8_t { + CMD, // single CS: CMD preamble + command word (a) + WRITE_W, // single CS: WRITE preamble + data word (a) + WRITE_REG, // single CS: WRITE preamble + addr(a) + value(b) + // (caller must enqueue CMD(TCON_REG_WR) before this) + READ_DEV_INFO, // single CS: READ preamble + dummy + read DevInfo struct + // (caller enqueues CMD(GET_DEV_INFO) first; loop HW_RDY gate + // ensures data is ready before this op runs) + READ_WORD, // single CS: READ preamble + dummy + read one 16-bit word + // into read_result_. Loop HW_RDY gate ensures data ready. + CHECK_LUT_IDLE, // checks read_result_; if non-zero, re-enqueues read sequence + SET_1BPP, // uses read_result_ to set UP1SR bit 2, enqueues writes + XFER_LISAR, // set image-buffer target address (2× reg write: 4 CS transactions) + XFER_AREA_CMD, // single CS: CMD preamble + TCON_LD_IMG_AREA + XFER_AREA_ARGS, // single CS: WRITE preamble + 5 area-parameter words + XFER_ROWS, // single CS: WRITE preamble + row pixel data (time-sliced) + XFER_AREA_END, // single CS: CMD preamble + TCON_LD_IMG_END + DPY_BUF_CMD, // single CS: CMD preamble + I80_CMD_DPY_BUF_AREA + DPY_BUF_ARGS, // single CS: WRITE preamble + 7 display-area words + GPIO_RESET_LOW, // drive RESET pin low + GPIO_RESET_HIGH, // drive RESET pin high + DELAY_MS, // park `delay_until_` for a few ms (no SPI) +}; + +struct Op { + OpType type; + uint16_t a{0}; + uint16_t b{0}; +}; + +// High-level controller phases. Each phase enqueues a sequence of Ops; when +// the queue drains, advance_phase_() runs the next phase. +// This separation keeps per-Op work tiny and predictable. +enum class Phase : uint8_t { + IDLE, + // Initialisation + INIT_RESET, // reset pulse + wake controller + packed-write enable + INIT_DEV_INFO, // GET_DEV_INFO and validate + INIT_VCOM, // write configured VCOM + INIT_TEMP, // force temperature for waveform LUT selection + INIT_DONE, // allocate framebuffer; transition to IDLE + // Update flow + UPDATE_PREPARE, // do_update_, compute dirty region, decide 4bpp/1bpp + UPDATE_TRANSFER, // one LD_IMG_AREA, time-sliced row streaming, one LD_IMG_END + UPDATE_REFRESH, // wait LUT idle, optionally enable 1bpp, send DPY_BUF_AREA + UPDATE_SLEEP, // optional deep sleep +}; + +class IT8951Display : public Display, + public spi::SPIDevice { + public: + IT8951Display(const char *name, uint16_t width, uint16_t height) : name_(name), width_(width), height_(height) { + this->row_width_ = this->compute_row_width_(); + this->buffer_length_ = static_cast(this->row_width_) * static_cast(height); + } + + // --- Component lifecycle --- + void setup() override; + void loop() override; + void dump_config() override; + void on_safe_shutdown() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + // --- Config setters (called from generated code) --- + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + void set_busy_pin(GPIOPin *pin) { this->busy_pin_ = pin; } + void set_enable_pins(std::vector pins) { this->enable_pins_ = std::move(pins); } + void set_reset_duration(uint32_t ms) { this->reset_duration_ = ms; } + void set_full_update_every(uint8_t n) { + this->full_update_every_ = n; + // Seed the counter so the very first update trips the full-update branch in + // prepare_update_region_, giving a freshly-booted panel a clean GC16 refresh + // before any partial (fast-waveform) updates begin. + this->partial_update_count_ = n; + } + void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } + void set_sleep_when_done(bool s) { this->sleep_when_done_ = s; } + void set_vcom(uint16_t vcom_mv) { this->vcom_ = vcom_mv; } + void set_vcom_register(uint16_t selector) { this->vcom_register_ = selector; } + void set_force_temperature(int16_t celsius) { + this->force_temperature_ = celsius; + this->force_temperature_set_ = true; + } + void set_use_legacy_dpy_area(bool use) { this->use_legacy_dpy_area_ = use; } + // Pixel format: true = 4bpp grayscale framebuffer, false = packed 1bpp + // monochrome framebuffer. Chosen at config time; the framebuffer is stored + // in this native format and every update uses the matching transfer path. + void set_grayscale(bool g) { this->grayscale_ = g; } + // Monochrome only: ordered-dither pale colours (true) vs a hard 50% threshold. + void set_dithering(bool d) { this->dithering_ = d; } + void set_update_mode(uint16_t m) { this->default_update_mode_ = static_cast(m); } + void set_transform(uint8_t t) { + this->transform_ = t; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } + + // --- Display API --- + void update() override; + void update_mode(UpdateMode mode); + DisplayType get_display_type() override { return this->grayscale_ ? DISPLAY_TYPE_GRAYSCALE : DISPLAY_TYPE_BINARY; } + void fill(Color color) override; + void clear() override { this->fill(Color::WHITE); } + void draw_pixel_at(int x, int y, Color color) override; + // Bulk pixel blit (used by LVGL and image rendering). Overridden to write + // straight into the framebuffer, avoiding the base class's per-pixel + // draw_pixel_at overhead (watchdog feed, clipping test, dirty-box clamps). + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } + int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + + protected: + int get_height_internal() override { return this->height_; } + int get_width_internal() override { return this->width_; } + + // --- Coord transform / dirty region --- + void update_effective_transform_(); + // Map display (logical) coordinates to native framebuffer coordinates by + // applying effective_transform_ (swap/mirror). Shared by rotate_coordinates_ + // and the bulk draw_pixels_at path. + void apply_transform_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); + void reset_dirty_region_(); + + // --- Framebuffer geometry / monochrome packing --- + // Bytes per row for the configured pixel format: 4bpp grayscale packs two + // pixels per byte; monochrome packs eight bits per byte, rounded up to a + // whole 16-pixel group (matching the controller's 8bpp-load / 1bpp trick). + uint16_t compute_row_width_() const { + return this->grayscale_ ? static_cast((static_cast(this->width_) + 1) / 2) + : static_cast(((static_cast(this->width_) + 15) / 16) * 2); + } + void set_mono_pixel_(uint16_t x, uint16_t y, bool value) const; + // Write a 4bpp grayscale nibble into the framebuffer (two pixels per byte). + void set_gray_pixel_(uint16_t x, uint16_t y, uint8_t nibble) const; + // Convert a color and write it at native framebuffer coordinates: a 4bpp + // nibble in grayscale mode, or an ordered-dithered bit in monochrome mode. + void write_pixel_native_(uint16_t x, uint16_t y, const Color &color) const; + + // --- Op queue / loop machinery --- + void enqueue_(OpType type, uint16_t a = 0, uint16_t b = 0); + void prepend_(OpType type, uint16_t a = 0, uint16_t b = 0); + bool is_busy_() const; + void process_op_(const Op &op); + void advance_phase_(); + void set_phase_(Phase next); + void start_update_(UpdateMode mode); + + // --- SPI primitives (each is one CS-asserted burst, fully non-blocking) --- + void spi_cmd_(uint16_t cmd); + void spi_write_word_(uint16_t value); + void spi_write_reg_(uint16_t addr, uint16_t value); + void spi_write_args_(const uint16_t *args, uint16_t count); + uint16_t spi_read_word_(); // non-blocking: HW_RDY confirmed by loop gate + void spi_read_dev_info_(); // non-blocking: HW_RDY confirmed by loop gate + + // --- Compound Ops (small bounded helpers) --- + void op_xfer_lisar_(); + void op_xfer_area_args_(); + void op_xfer_area_end_(); + bool op_xfer_rows_(); // returns true when current update area fully sent + void op_dpy_buf_args_(); + void op_check_lut_idle_(); + void op_set_1bpp_(); + + // --- Phase enqueuers --- + void enqueue_init_reset_(); + void enqueue_init_dev_info_(); + void enqueue_init_vcom_(); + void enqueue_init_temp_(); + void enqueue_update_transfer_(); + void enqueue_update_refresh_(); + void enqueue_update_sleep_(); + + bool prepare_update_region_(UpdateMode &mode); + + // --- Recovery --- + void recover_(); + + // --- State --- + static constexpr uint32_t BUSY_TIMEOUT_MS = 5000; + + StaticOpQueue queue_; + Phase phase_{Phase::IDLE}; + uint32_t delay_until_{0}; + uint32_t phase_started_at_{0}; + // Requests a continuous (non-throttled) main loop while streaming image data + // so 20ms transfer slices aren't separated by the ~16ms default loop interval. + HighFrequencyLoopRequester high_freq_; + + // Pending update bookkeeping + bool update_pending_{false}; + UpdateMode pending_update_mode_{UPDATE_MODE_NONE}; + UpdateMode active_mode_{UPDATE_MODE_NONE}; + uint16_t area_x_{0}, area_y_{0}, area_w_{0}, area_h_{0}; + uint16_t transfer_row_{0}; + bool initialised_{false}; + // True once TCON_SLEEP has been sent and the controller has not been woken + // since. The next update must issue TCON_SYS_RUN before any SPI op. + bool asleep_{false}; + uint32_t partial_update_count_{0}; + uint32_t update_started_at_{0}; + + // Read result storage for decomposed read-modify-write op sequences + uint16_t read_result_{0}; + + // Device info + DevInfo dev_info_{}; + uint16_t img_buf_addr_l_{0}; + uint16_t img_buf_addr_h_{0}; + + // Configured properties + const char *name_; + uint16_t width_; + uint16_t height_; + uint16_t row_width_; + size_t buffer_length_{}; + uint8_t *buffer_{}; + uint8_t transform_{0}; + uint8_t effective_transform_{0}; + uint8_t full_update_every_{1}; + uint32_t reset_duration_{10}; + uint16_t vcom_{2300}; + uint16_t vcom_register_{I80_CMD_VCOM_WRITE}; + int16_t force_temperature_{DEFAULT_FORCE_TEMP_C}; + bool force_temperature_set_{false}; + bool use_legacy_dpy_area_{false}; + bool invert_colors_{false}; + bool sleep_when_done_{false}; + // Pixel format selector (see set_grayscale): true = 4bpp grayscale, + // false = packed 1bpp monochrome. + bool grayscale_{true}; + // Monochrome dithering (see set_dithering): true = ordered dither. + bool dithering_{true}; + UpdateMode default_update_mode_{UPDATE_MODE_NONE}; + GPIOPin *reset_pin_{nullptr}; + GPIOPin *busy_pin_{nullptr}; + // GPIOs driven high during setup to power on the panel (empty if unused). + std::vector enable_pins_; + + // Dirty region (pixel coordinates of bounding box of changes since last update) + uint16_t x_low_{0}, y_low_{0}, x_high_{0}, y_high_{0}; + + // Saved data rate so we can probe slow then run fast + uint32_t configured_data_rate_{0}; + + // Consecutive recovery attempts; used to give up rather than infinite-loop + // when the controller is unresponsive (e.g. wiring issue). + uint8_t recovery_attempts_{0}; + + // DevInfo read retry counter (controller often returns garbage on the first + // read after reset; the original driver retried up to 3 times with 100ms + // between attempts). + uint8_t dev_info_attempts_{0}; +}; + +// --- Automation action --- +template class IT8951UpdateAction : public Action { + public: + explicit IT8951UpdateAction(IT8951Display *display) : display_(display) {} + TEMPLATABLE_VALUE(UpdateMode, mode) + + protected: + void play(const Ts &...x) override { + if (!this->display_->is_ready()) + return; + if (this->mode_.has_value()) { + this->display_->update_mode(this->mode_.value(x...)); + } else { + this->display_->update(); + } + } + + IT8951Display *display_; +}; + +} // namespace esphome::it8951 diff --git a/esphome/components/it8951/it8951_defs.h b/esphome/components/it8951/it8951_defs.h new file mode 100644 index 0000000000..9a7291eb4a --- /dev/null +++ b/esphome/components/it8951/it8951_defs.h @@ -0,0 +1,168 @@ +#pragma once + +#include + +namespace esphome::it8951 { + +struct DevInfo { + uint16_t panel_width{0}; + uint16_t panel_height{0}; + uint16_t img_buf_addr_l{0}; + uint16_t img_buf_addr_h{0}; + uint16_t fw_version[8]{}; + uint16_t lut_version[8]{}; +}; + +// --- IT8951 SPI packet preambles --- +static constexpr uint16_t PACKET_TYPE_CMD = 0x6000; +static constexpr uint16_t PACKET_TYPE_WRITE = 0x0000; +static constexpr uint16_t PACKET_TYPE_READ = 0x1000; + +// --- Built-in I80 commands --- +static constexpr uint16_t TCON_SYS_RUN = 0x0001; +static constexpr uint16_t TCON_STANDBY = 0x0002; +static constexpr uint16_t TCON_SLEEP = 0x0003; +static constexpr uint16_t TCON_REG_RD = 0x0010; +static constexpr uint16_t TCON_REG_WR = 0x0011; + +static constexpr uint16_t TCON_LD_IMG = 0x0020; +static constexpr uint16_t TCON_LD_IMG_AREA = 0x0021; +static constexpr uint16_t TCON_LD_IMG_END = 0x0022; + +// --- I80 user-defined commands --- +static constexpr uint16_t I80_CMD_DPY_AREA = 0x0034; +static constexpr uint16_t I80_CMD_GET_DEV_INFO = 0x0302; +static constexpr uint16_t I80_CMD_DPY_BUF_AREA = 0x0037; +static constexpr uint16_t I80_CMD_VCOM = 0x0039; +static constexpr uint16_t I80_CMD_VCOM_READ = 0x0000; +// VCOM write selectors. Different IT8951-driven panels accept different +// selector values for the VCOM SET sub-command. Most panels (m5stack-m5paper, +// generic dev kits) accept 0x0001. Some panels — notably the Seeed +// reTerminal E1003 — only respond to selector 0x0002 and silently ignore +// 0x0001, leaving VCOM at its default and making grayscale waveforms +// (GC16/GL16) ineffective even though INIT still works. +static constexpr uint16_t I80_CMD_VCOM_WRITE = 0x0001; +static constexpr uint16_t I80_CMD_VCOM_WRITE_ALT = 0x0002; + +// Force temperature command. The IT8951 selects waveform LUTs based on +// panel temperature; if it is left at the controller default, panels with +// auto-temperature disabled (notably the Seeed reTerminal E1003) will +// run waveforms against a mismatched LUT, leaving pixels visually +// unchanged even though the LUT engine completes a full cycle. The +// selector word selects the operation (0x0001 = write); the value word +// is the temperature in degrees Celsius. +static constexpr uint16_t I80_CMD_FORCE_TEMP = 0x0040; +static constexpr uint16_t I80_CMD_FORCE_TEMP_WRITE = 0x0001; +static constexpr int16_t DEFAULT_FORCE_TEMP_C = 25; + +// --- Pixel mode (bits per pixel encoding) --- +static constexpr uint8_t PIXEL_2BPP = 0; +static constexpr uint8_t PIXEL_3BPP = 1; +static constexpr uint8_t PIXEL_4BPP = 2; +static constexpr uint8_t PIXEL_8BPP = 3; + +// --- Endian flags for LD_IMG_AREA --- +static constexpr uint8_t LDIMG_L_ENDIAN = 0; +static constexpr uint8_t LDIMG_B_ENDIAN = 1; + +// --- SPI probe frequency used for initial controller handshake --- +static constexpr uint32_t SPI_PROBE_FREQUENCY = 1'000'000; + +// --- Refresh modes --- +/* + INIT The initialization (INIT) mode is + used to completely erase the display and leave it in the white state. It is + useful for situations where the display information in memory is not a faithful + representation of the optical state of the display, for example, after the + device receives power after it has been fully powered down. This waveform + switches the display several times and leaves it in the white state. + + DU + The direct update (DU) is a very fast, non-flashy update. This mode supports + transitions from any graytone to black or white only. It cannot be used to + update to any graytone other than black or white. The fast update time for this + mode makes it useful for response to touch sensor or pen input or menu selection + indictors. + + GC16 + The grayscale clearing (GC16) mode is used to update the full display and + provide a high image quality. When GC16 is used with Full Display Update the + entire display will update as the new image is written. If a Partial Update + command is used the only pixels with changing graytone values will update. The + GC16 mode has 16 unique gray levels. + + GL16 + The GL16 waveform is primarily used to update sparse content on a white + background, such as a page of anti-aliased text, with reduced flash. The + GL16 waveform has 16 unique gray levels. + + GLR16 + The GLR16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. The GLR16 mode supports 16 graytones. If only the even pixel states + are used (0, 2, 4, … 30), the mode will behave exactly as a traditional GL16 + waveform mode. If a separately-supplied image preprocessing algorithm is used, + the transitions invoked by the pixel states 29 and 31 are used to improve + display quality. For the AF waveform, it is assured that the GLR16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + GLD16 + The GLD16 mode is used in conjunction with an image preprocessing algorithm to + update sparse content on a white background with reduced flash and reduced image + artifacts. It is recommended to be used only with the full display update. The + GLD16 mode supports 16 graytones. If only the even pixel states are used (0, 2, + 4, … 30), the mode will behave exactly as a traditional GL16 waveform mode. If a + separately-supplied image preprocessing algorithm is used, the transitions + invoked by the pixel states 29 and 31 are used to refresh the background with a + lighter flash compared to GC16 mode following a predetermined pixel map as + encoded in the waveform file, and reduce image artifacts even more compared to + the GLR16 mode. For the AF waveform, it is assured that the GLD16 waveform data + will point to the same voltage lists as the GL16 data and does not need to be + stored in a separate memory. + + DU4 + The DU4 is a fast update time (similar to DU), non-flashy waveform. This mode + supports transitions from any gray tone to gray tones 1,6,11,16 represented by + pixel states [0 10 20 30]. The combination of fast update time and four gray + tones make it useful for anti-aliased text in menus. There is a moderate + increase in ghosting compared with GC16. + + A2 + The A2 mode is a fast, non-flash update mode designed for fast paging turning or + simple black/white animation. This mode supports transitions from and to black + or white only. It cannot be used to update to any graytone other than black or + white. The recommended update sequence to transition into repeated A2 updates is + shown in Figure 1. The use of a white image in the transition from 4-bit to + 1-bit images will reduce ghosting and improve image quality for A2 updates. + */ +enum UpdateMode : uint16_t { + UPDATE_MODE_INIT = 0, + UPDATE_MODE_DU = 1, + UPDATE_MODE_GC16 = 2, + UPDATE_MODE_GL16 = 3, + UPDATE_MODE_GLR16 = 4, + UPDATE_MODE_GLD16 = 5, + UPDATE_MODE_DU4 = 6, + UPDATE_MODE_A2 = 7, + UPDATE_MODE_NONE = 8, +}; + +// --- Registers --- +static constexpr uint16_t DISPLAY_REG_BASE = 0x1000; +static constexpr uint16_t UP1SR = DISPLAY_REG_BASE + 0x138; +static constexpr uint16_t LUTAFSR = DISPLAY_REG_BASE + 0x224; +static constexpr uint16_t BGVR = DISPLAY_REG_BASE + 0x250; + +static constexpr uint16_t I80CPCR = 0x0004; + +static constexpr uint16_t MCSR_BASE_ADDR = 0x0200; +static constexpr uint16_t LISAR = MCSR_BASE_ADDR + 0x0008; + +// Display orientation flags +static constexpr uint8_t TRANSFORM_NONE = 0; +static constexpr uint8_t TRANSFORM_MIRROR_X = 1; +static constexpr uint8_t TRANSFORM_MIRROR_Y = 2; +static constexpr uint8_t TRANSFORM_SWAP_XY = 4; + +} // namespace esphome::it8951 diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.h b/esphome/components/jsn_sr04t/jsn_sr04t.h index f9d07ea539..5368ec683c 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.h +++ b/esphome/components/jsn_sr04t/jsn_sr04t.h @@ -13,7 +13,7 @@ enum Model { AJ_SR04M, }; -class Jsnsr04tComponent : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { +class Jsnsr04tComponent final : public sensor::Sensor, public PollingComponent, public uart::UARTDevice { public: void set_model(Model model) { this->model_ = model; } diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 28fdcd41ef..3cb89a6cd9 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -15,8 +15,8 @@ async def to_code(config): if CORE.is_esp32: from esphome.components.esp32 import add_idf_component - add_idf_component(name="bblanchon/arduinojson", ref="7.4.2") + add_idf_component(name="bblanchon/arduinojson", ref="7.4.3") else: - cg.add_library("bblanchon/ArduinoJson", "7.4.2") + cg.add_library("bblanchon/ArduinoJson", "7.4.3") cg.add_define("USE_JSON") cg.add_global(json_ns.using) diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.h b/esphome/components/kamstrup_kmp/kamstrup_kmp.h index a4eacec453..57a89f77a1 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.h +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.h @@ -73,7 +73,7 @@ static const char *const UNITS[] = { "mm:dd", "", "bar", "RTC", "ASCII", "m3 x 10", "ton x 10", "GJ x 10", "minutes", "Bitfield", "s", "ms", "days", "RTC-Q", "Datetime"}; -class KamstrupKMPComponent : public PollingComponent, public uart::UARTDevice { +class KamstrupKMPComponent final : public PollingComponent, public uart::UARTDevice { public: void set_heat_energy_sensor(sensor::Sensor *sensor) { this->heat_energy_sensor_ = sensor; } void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; } diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index 134ac245bf..75ec432ad9 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_KELVIN, UNIT_KILOWATT, + UNIT_LITRE_PER_HOUR, ) CODEOWNERS = ["@cfeenstra1024"] @@ -37,7 +38,6 @@ CONF_TEMP2 = "temp2" CONF_TEMP_DIFF = "temp_diff" UNIT_GIGA_JOULE = "GJ" -UNIT_LITRE_PER_HOUR = "l/h" # Note: The sensor units are set automatically based un the received data from the meter CONFIG_SCHEMA = ( diff --git a/esphome/components/key_collector/key_collector.h b/esphome/components/key_collector/key_collector.h index 27209c50df..c9eeabeb2d 100644 --- a/esphome/components/key_collector/key_collector.h +++ b/esphome/components/key_collector/key_collector.h @@ -7,7 +7,7 @@ namespace esphome::key_collector { -class KeyCollector : public Component { +class KeyCollector final : public Component { public: void loop() override; void dump_config() override; @@ -54,11 +54,11 @@ class KeyCollector : public Component { bool enabled_{}; }; -template class EnableAction : public Action, public Parented { +template class EnableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(true); } }; -template class DisableAction : public Action, public Parented { +template class DisableAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_enabled(false); } }; diff --git a/esphome/components/kmeteriso/kmeteriso.h b/esphome/components/kmeteriso/kmeteriso.h index d5a2f9a01b..bd92a6011f 100644 --- a/esphome/components/kmeteriso/kmeteriso.h +++ b/esphome/components/kmeteriso/kmeteriso.h @@ -8,7 +8,7 @@ namespace esphome::kmeteriso { /// This class implements support for the KMeterISO thermocouple sensor. -class KMeterISOComponent : public PollingComponent, public i2c::I2CDevice { +class KMeterISOComponent final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *t) { this->temperature_sensor_ = t; } void set_internal_temperature_sensor(sensor::Sensor *t) { this->internal_temperature_sensor_ = t; } diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index bbd93a22ce..46681843d2 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -6,7 +6,7 @@ namespace esphome::kuntze { -class Kuntze : public PollingComponent, public modbus::ModbusDevice { +class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_ph_sensor(sensor::Sensor *ph_sensor) { ph_sensor_ = ph_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 96b6334730..c11ede9db6 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -15,13 +15,14 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PH, ) +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] AUTO_LOAD = ["modbus"] kuntze_ns = cg.esphome_ns.namespace("kuntze") -Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusDevice) +Kuntze = kuntze_ns.class_("Kuntze", cg.PollingComponent, modbus.ModbusClientDevice) CONF_DIS1 = "dis1" CONF_DIS2 = "dis2" @@ -88,10 +89,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("kuntze", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_PH in config: conf = config[CONF_PH] diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 42aa9a15a1..46f773873a 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -19,7 +19,7 @@ enum LC709203FBatteryVoltage { LC709203F_BATTERY_VOLTAGE_3_7 = 0x0001, }; -class Lc709203f : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Lc709203f final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.h b/esphome/components/lcd_gpio/gpio_lcd_display.h index dd9ea5929c..17fcf7ea23 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.h +++ b/esphome/components/lcd_gpio/gpio_lcd_display.h @@ -10,7 +10,7 @@ class GPIOLCDDisplay; using gpio_lcd_writer_t = display::DisplayWriter; -class GPIOLCDDisplay : public lcd_base::LCDDisplay { +class GPIOLCDDisplay final : public lcd_base::LCDDisplay { public: void set_writer(gpio_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/lcd_menu/lcd_menu.h b/esphome/components/lcd_menu/lcd_menu.h index ae1c2502fe..6fa61fdf6a 100644 --- a/esphome/components/lcd_menu/lcd_menu.h +++ b/esphome/components/lcd_menu/lcd_menu.h @@ -11,7 +11,7 @@ namespace esphome::lcd_menu { /** Class to display a hierarchical menu. * */ -class LCDCharacterMenuComponent : public display_menu_base::DisplayMenuComponent { +class LCDCharacterMenuComponent final : public display_menu_base::DisplayMenuComponent { public: void set_display(lcd_base::LCDDisplay *display) { this->display_ = display; } void set_dimensions(uint8_t columns, uint8_t rows) { diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.h b/esphome/components/lcd_pcf8574/pcf8574_display.h index 9ec5ad71af..5af087add5 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.h +++ b/esphome/components/lcd_pcf8574/pcf8574_display.h @@ -11,7 +11,7 @@ class PCF8574LCDDisplay; using pcf8574_lcd_writer_t = display::DisplayWriter; -class PCF8574LCDDisplay : public lcd_base::LCDDisplay, public i2c::I2CDevice { +class PCF8574LCDDisplay final : public lcd_base::LCDDisplay, public i2c::I2CDevice { public: void set_writer(pcf8574_lcd_writer_t &&writer) { this->writer_ = std::move(writer); } void setup() override; diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h index 614453b575..b0b9591d37 100644 --- a/esphome/components/ld2410/automation.h +++ b/esphome/components/ld2410/automation.h @@ -6,7 +6,7 @@ namespace esphome::ld2410 { -template class BluetoothPasswordSetAction : public Action { +template class BluetoothPasswordSetAction final : public Action { public: explicit BluetoothPasswordSetAction(LD2410Component *ld2410_comp) : ld2410_comp_(ld2410_comp) {} TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/ld2410/button/factory_reset_button.h b/esphome/components/ld2410/button/factory_reset_button.h index 715a8c4056..1da7c81337 100644 --- a/esphome/components/ld2410/button/factory_reset_button.h +++ b/esphome/components/ld2410/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2410/button/query_button.h b/esphome/components/ld2410/button/query_button.h index 7a786901ae..4f3f147e67 100644 --- a/esphome/components/ld2410/button/query_button.h +++ b/esphome/components/ld2410/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2410/button/restart_button.h b/esphome/components/ld2410/button/restart_button.h index 9bf8639a8c..70e0a74c9a 100644 --- a/esphome/components/ld2410/button/restart_button.h +++ b/esphome/components/ld2410/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 31186b135f..a0cce36d16 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -38,7 +38,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 50; static constexpr uint8_t TOTAL_GATES = 9; // Total number of gates supported by the LD2410 -class LD2410Component : public Component, public uart::UARTDevice { +class LD2410Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(out_pin_presence_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2410/number/gate_threshold_number.h b/esphome/components/ld2410/number/gate_threshold_number.h index 63491f18d3..68359a10d4 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.h +++ b/esphome/components/ld2410/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2410/number/light_threshold_number.h b/esphome/components/ld2410/number/light_threshold_number.h index 3c5e433416..6e1a5ca4a4 100644 --- a/esphome/components/ld2410/number/light_threshold_number.h +++ b/esphome/components/ld2410/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2410/number/max_distance_timeout_number.h b/esphome/components/ld2410/number/max_distance_timeout_number.h index 35f4cbbfae..29b19c2022 100644 --- a/esphome/components/ld2410/number/max_distance_timeout_number.h +++ b/esphome/components/ld2410/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index fb1d016b1f..b06ce139ad 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index be2389d36e..0c5409b7b1 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index 608c311af4..a8a16598b1 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2410/switch/bluetooth_switch.h b/esphome/components/ld2410/switch/bluetooth_switch.h index 07804e2292..cc56b2cda0 100644 --- a/esphome/components/ld2410/switch/bluetooth_switch.h +++ b/esphome/components/ld2410/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.h b/esphome/components/ld2410/switch/engineering_mode_switch.h index 4dd8e16653..49243a73ad 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.h +++ b/esphome/components/ld2410/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2410 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h index 1ef6b23b80..a6ea8f7365 100644 --- a/esphome/components/ld2412/button/factory_reset_button.h +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class FactoryResetButton : public button::Button, public Parented { +class FactoryResetButton final : public button::Button, public Parented { public: FactoryResetButton() = default; diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h index 373e135802..71e2ab14e8 100644 --- a/esphome/components/ld2412/button/query_button.h +++ b/esphome/components/ld2412/button/query_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class QueryButton : public button::Button, public Parented { +class QueryButton final : public button::Button, public Parented { public: QueryButton() = default; diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h index 80c79f5e7d..668ce1a8e6 100644 --- a/esphome/components/ld2412/button/restart_button.h +++ b/esphome/components/ld2412/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h index b1f2127896..3b24f5dcf8 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { +class StartDynamicBackgroundCorrectionButton final : public button::Button, public Parented { public: StartDynamicBackgroundCorrectionButton() = default; diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 306e7ae31d..f722f938ae 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -36,7 +36,7 @@ using namespace ld24xx; static constexpr uint8_t MAX_LINE_LENGTH = 54; // Max characters for serial buffer static constexpr uint8_t TOTAL_GATES = 14; // Total number of gates supported by the LD2412 -class LD2412Component : public Component, public uart::UARTDevice { +class LD2412Component final : public Component, public uart::UARTDevice { #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(dynamic_background_correction_status) SUB_BINARY_SENSOR(moving_target) diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 78c2e54d82..918b6dfad1 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class GateThresholdNumber : public number::Number, public Parented { +class GateThresholdNumber final : public number::Number, public Parented { public: GateThresholdNumber(uint8_t gate); diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index 81fd73111c..f62d523af3 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightThresholdNumber : public number::Number, public Parented { +class LightThresholdNumber final : public number::Number, public Parented { public: LightThresholdNumber() = default; diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h index c1e947fa19..4a3478d48a 100644 --- a/esphome/components/ld2412/number/max_distance_timeout_number.h +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class MaxDistanceTimeoutNumber : public number::Number, public Parented { +class MaxDistanceTimeoutNumber final : public number::Number, public Parented { public: MaxDistanceTimeoutNumber() = default; diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 4666dd2fa0..46ec9be1d1 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BaudRateSelect : public select::Select, public Parented { +class BaudRateSelect final : public select::Select, public Parented { public: BaudRateSelect() = default; diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index d3b7fad2f9..be8dba90b5 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class DistanceResolutionSelect : public select::Select, public Parented { +class DistanceResolutionSelect final : public select::Select, public Parented { public: DistanceResolutionSelect() = default; diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index 9f86189878..c8988fda78 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class LightOutControlSelect : public select::Select, public Parented { +class LightOutControlSelect final : public select::Select, public Parented { public: LightOutControlSelect() = default; diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 0c0d1fa550..8fd4a86e43 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class BluetoothSwitch : public switch_::Switch, public Parented { +class BluetoothSwitch final : public switch_::Switch, public Parented { public: BluetoothSwitch() = default; diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index 4e75a8a185..defeb4c76b 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -5,7 +5,7 @@ namespace esphome::ld2412 { -class EngineeringModeSwitch : public switch_::Switch, public Parented { +class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: EngineeringModeSwitch() = default; diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h index ec52312f92..47492e38c2 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor { +class LD2420BinarySensor final : public LD2420Listener, public Component, public binary_sensor::BinarySensor { public: void dump_config() override; void set_presence_sensor(binary_sensor::BinarySensor *bsensor) { this->presence_bsensor_ = bsensor; }; diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h index 72171ef386..b769e18a46 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.h +++ b/esphome/components/ld2420/button/reconfig_buttons.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420ApplyConfigButton final : public button::Button, public Parented { public: LD2420ApplyConfigButton() = default; @@ -13,7 +13,7 @@ class LD2420ApplyConfigButton : public button::Button, public Parented { +class LD2420RevertConfigButton final : public button::Button, public Parented { public: LD2420RevertConfigButton() = default; @@ -21,7 +21,7 @@ class LD2420RevertConfigButton : public button::Button, public Parented { +class LD2420RestartModuleButton final : public button::Button, public Parented { public: LD2420RestartModuleButton() = default; @@ -29,7 +29,7 @@ class LD2420RestartModuleButton : public button::Button, public Parented { +class LD2420FactoryResetButton final : public button::Button, public Parented { public: LD2420FactoryResetButton() = default; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 358793fe64..ae44b16065 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -40,7 +40,7 @@ class LD2420Listener { virtual void on_fw_version(std::string &fw){}; }; -class LD2420Component : public Component, public uart::UARTDevice { +class LD2420Component final : public Component, public uart::UARTDevice { public: struct CmdFrameT { uint32_t header{0}; diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index 8a8b9c61b1..e1c12e023a 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420TimeoutNumber final : public number::Number, public Parented { public: LD2420TimeoutNumber() = default; @@ -13,7 +13,7 @@ class LD2420TimeoutNumber : public number::Number, public Parented { +class LD2420MinDistanceNumber final : public number::Number, public Parented { public: LD2420MinDistanceNumber() = default; @@ -21,7 +21,7 @@ class LD2420MinDistanceNumber : public number::Number, public Parented { +class LD2420MaxDistanceNumber final : public number::Number, public Parented { public: LD2420MaxDistanceNumber() = default; @@ -29,7 +29,7 @@ class LD2420MaxDistanceNumber : public number::Number, public Parented { +class LD2420GateSelectNumber final : public number::Number, public Parented { public: LD2420GateSelectNumber() = default; @@ -37,7 +37,7 @@ class LD2420GateSelectNumber : public number::Number, public Parented { +class LD2420MoveSensFactorNumber final : public number::Number, public Parented { public: LD2420MoveSensFactorNumber() = default; @@ -45,7 +45,7 @@ class LD2420MoveSensFactorNumber : public number::Number, public Parented { +class LD2420StillSensFactorNumber final : public number::Number, public Parented { public: LD2420StillSensFactorNumber() = default; @@ -53,7 +53,7 @@ class LD2420StillSensFactorNumber : public number::Number, public Parented { +class LD2420StillThresholdNumbers final : public number::Number, public Parented { public: LD2420StillThresholdNumbers() = default; LD2420StillThresholdNumbers(uint8_t gate); @@ -63,7 +63,7 @@ class LD2420StillThresholdNumbers : public number::Number, public Parented { +class LD2420MoveThresholdNumbers final : public number::Number, public Parented { public: LD2420MoveThresholdNumbers() = default; LD2420MoveThresholdNumbers(uint8_t gate); diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index c1b8e0b11b..e5eb5d82bd 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Select : public Component, public select::Select, public Parented { +class LD2420Select final : public Component, public select::Select, public Parented { public: LD2420Select() = default; diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h index 4849cfa047..4ccfc19081 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.h +++ b/esphome/components/ld2420/sensor/ld2420_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { +class LD2420Sensor final : public LD2420Listener, public Component, public sensor::Sensor { public: void dump_config() override; void set_distance_sensor(sensor::Sensor *sensor) { this->distance_sensor_ = sensor; } diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h index 1932eaaf69..da295fe7ca 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::ld2420 { -class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor { +class LD2420TextSensor final : public LD2420Listener, public Component, public text_sensor::TextSensor { public: void dump_config() override; void set_fw_version_text_sensor(text_sensor::TextSensor *tsensor) { this->fw_version_text_sensor_ = tsensor; }; diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index bf5cdb9305..b0a243f2e4 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -13,7 +13,7 @@ namespace esphome::ledc { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern uint8_t next_ledc_channel; -class LEDCOutput : public output::FloatOutput, public Component { +class LEDCOutput final : public output::FloatOutput, public Component { public: explicit LEDCOutput(InternalGPIOPin *pin) : pin_(pin) { this->channel_ = next_ledc_channel++; } @@ -43,7 +43,7 @@ class LEDCOutput : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LEDCOutput *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index afe0360c22..62cef331fd 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [ "CFG_SUPPORT_BLE=0", ] +# Board ids upstream LibreTiny renamed; configs written against the old id +# keep validating and building against the new one (with a warning). +# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0. +_RENAMED_BOARDS = { + "generic-ln882hki": "generic-ln882h", +} + def _detect_variant(value): if KEY_LIBRETINY not in CORE.data: raise cv.Invalid("Family component didn't populate core data properly!") component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] board = value[CONF_BOARD] + if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)): + _LOGGER.warning( + "Board '%s' was renamed to '%s'; please update your configuration", + board, + renamed, + ) + value = value.copy() + value[CONF_BOARD] = board = renamed # read board-default family if not specified if board not in component.boards: if CONF_FAMILY not in value: @@ -158,15 +173,13 @@ def only_on_family(*, supported=None, unsupported=None): def get_download_types(storage_json: StorageJSON = None): """Binary-download entries for a built LibreTiny firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ types = [ { @@ -213,14 +226,14 @@ def _notify_old_style(config): # The dev and latest branches will be at *least* this version, which is what matters. # Use GitHub releases directly to avoid PlatformIO moderation delays. ARDUINO_VERSIONS = { - "dev": (cv.Version(1, 12, 1), "https://github.com/libretiny-eu/libretiny.git"), + "dev": (cv.Version(1, 13, 0), "https://github.com/libretiny-eu/libretiny.git"), "latest": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), "recommended": ( - cv.Version(1, 12, 1), - "https://github.com/libretiny-eu/libretiny.git#v1.12.1", + cv.Version(1, 13, 0), + "https://github.com/libretiny-eu/libretiny.git#v1.13.0", ), } @@ -259,7 +272,10 @@ FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, + # Raw PlatformIO package source — build internal, not a UI field. + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, cv.Optional(CONF_LOGLEVEL, default="warn"): ( cv.one_of(*LT_LOGLEVELS, upper=True) ), diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index 6ca16f277f..791a2659a9 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -359,7 +359,9 @@ if __name__ == "__main__": check_base_code(BASE_CODE_INIT) # list all boards from ltchiptool components_dir = Path(__file__).parent.parent - boards = [Board(b) for b in Board.get_list()] + # Board.get_list() returns glob (filesystem) order, which is non-deterministic + # and produces noisy diffs on regeneration; sort by board id for stable output. + boards = sorted((Board(b) for b in Board.get_list()), key=lambda b: b.name) # keep track of all supported root- and chip-families components = set() families = {} diff --git a/esphome/components/libretiny/gpio_arduino.cpp b/esphome/components/libretiny/gpio_arduino.cpp index 1af0dce16d..b1a37cb225 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.gpio"; +static const char *const TAG = "libretiny.gpio"; static int IRAM_ATTR flags_to_mode(gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index 5f1fa3fec7..da477fde36 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -5,7 +5,7 @@ namespace esphome::libretiny { -class ArduinoInternalGPIOPin : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index c01661b3a6..9bbbd66be4 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -6,7 +6,7 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.component"; +static const char *const TAG = "libretiny"; void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/libretiny/lt_component.h b/esphome/components/libretiny/lt_component.h index 896f1901e3..3850a679b2 100644 --- a/esphome/components/libretiny/lt_component.h +++ b/esphome/components/libretiny/lt_component.h @@ -14,7 +14,7 @@ namespace esphome::libretiny { -class LTComponent : public Component { +class LTComponent final : public Component { public: float get_setup_priority() const override; void dump_config() override; diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.h b/esphome/components/libretiny_pwm/libretiny_pwm.h index f7737be386..f0ea0228b7 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.h +++ b/esphome/components/libretiny_pwm/libretiny_pwm.h @@ -9,7 +9,7 @@ namespace esphome::libretiny_pwm { -class LibreTinyPWM : public output::FloatOutput, public Component { +class LibreTinyPWM final : public output::FloatOutput, public Component { public: explicit LibreTinyPWM(InternalGPIOPin *pin) : pin_(pin) {} @@ -34,7 +34,7 @@ class LibreTinyPWM : public output::FloatOutput, public Component { bool initialized_ = false; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(LibreTinyPWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 0202ad380a..57e9caf289 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -21,7 +21,7 @@ Color color_from_light_color_values(LightColorValues val); /// Use a custom state class for addressable lights, to allow type system to discriminate between addressable and /// non-addressable lights. -class AddressableLightState : public LightState { +class AddressableLightState final : public LightState { using LightState::LightState; }; diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 260414f033..ced15dfc60 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -8,7 +8,7 @@ namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(LightState *state) : state_(state) {} @@ -43,7 +43,7 @@ template class ToggleAction : public A // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class LightControlAction : public Action { +template class LightControlAction final : public Action { public: using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t &...); LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} @@ -59,7 +59,7 @@ template class LightControlAction : public Action { ApplyFn apply_; }; -template class DimRelativeAction : public Action { +template class DimRelativeAction final : public Action { public: explicit DimRelativeAction(LightState *parent) : parent_(parent) {} @@ -108,7 +108,7 @@ template class DimRelativeAction : pub // at compile time so the chosen branch is the only one that gets instantiated // per action site. `include_none` is runtime so a single set of templates // covers both the "wrap through None" and "skip None" variants. -template class LightEffectCycleAction : public Action { +template class LightEffectCycleAction final : public Action { public: explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} @@ -145,7 +145,7 @@ template class LightEffectCycleAction : public Act bool include_none_{false}; }; -template class LightIsOnCondition : public Condition { +template class LightIsOnCondition final : public Condition { public: explicit LightIsOnCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return this->state_->current_values.is_on(); } @@ -153,7 +153,7 @@ template class LightIsOnCondition : public Condition { protected: LightState *state_; }; -template class LightIsOffCondition : public Condition { +template class LightIsOffCondition final : public Condition { public: explicit LightIsOffCondition(LightState *state) : state_(state) {} bool check(const Ts &...x) override { return !this->state_->current_values.is_on(); } @@ -162,7 +162,7 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { a_light->add_remote_values_listener(this); @@ -187,7 +187,7 @@ class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { bool last_on_; }; -class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { +class LightTurnOffTrigger final : public Trigger<>, public LightTargetStateReachedListener { public: explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { a_light->add_target_state_reached_listener(this); @@ -205,7 +205,7 @@ class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedList LightState *light_; }; -class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { +class LightStateTrigger final : public Trigger<>, public LightRemoteValuesListener { public: explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } @@ -216,7 +216,7 @@ class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { // due to the template. It's just a temporary warning anyway. void addressableset_warn_about_scale(const char *field); -template class AddressableSet : public Action { +template class AddressableSet final : public Action { public: explicit AddressableSet(LightState *parent) : parent_(parent) {} diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7b28065e4e..2b13b40a16 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -213,17 +213,19 @@ LightColorValues LightCall::validate_() { // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. bool explicit_turn_off_request = this->has_state() && !this->state_; - // Turn off when brightness is set to zero, and reset brightness (so that it has nonzero brightness when turned on). - if (this->has_brightness() && this->brightness_ == 0.0f) { + // Treat zero brightness as an implicit turn-off when no state was explicitly requested. + if (this->has_brightness() && this->brightness_ == 0.0f && !this->has_state()) { this->state_ = false; this->set_flag_(FLAG_HAS_STATE); - if (color_mode & ColorCapability::BRIGHTNESS) { - // Reset brightness so the light has nonzero brightness when turned back on. + } + + // Make sure a turn-on makes the light visible: if the resulting brightness would be zero + // (e.g. restored from a brightness=0 turn-off), reset it to full brightness. + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) { + float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness(); + if (brightness == 0.0f) { this->brightness_ = 1.0f; - } else { - // Light doesn't support brightness; clear the flag to avoid a spurious - // "brightness not supported" warning during capability validation. - this->clear_flag_(FLAG_HAS_BRIGHTNESS); + this->set_flag_(FLAG_HAS_BRIGHTNESS); } } diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 5cafa9fe82..e431a06df6 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -315,14 +315,14 @@ class LightColorValues { if (this->color_temperature_ <= 0) { return this->color_temperature_; } - return 1000000.0 / this->color_temperature_; + return 1000000.0f / this->color_temperature_; } /// Set the color temperature property of these light color values in kelvin. void set_color_temperature_kelvin(float color_temperature) { if (color_temperature <= 0) { return; } - this->color_temperature_ = 1000000.0 / color_temperature; + this->color_temperature_ = 1000000.0f / color_temperature; } /// Get the cold white property of these light color values. In range 0.0 to 1.0. diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 61fe098ad7..34e192a034 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -47,7 +47,7 @@ class LightTransitionTransformer : public LightTransformer { LightColorValues &start = this->changing_color_mode_ && p > 0.5f ? this->intermediate_values_ : this->start_values_; LightColorValues &end = this->changing_color_mode_ && p < 0.5f ? this->intermediate_values_ : this->end_values_; if (this->changing_color_mode_) - p = p < 0.5f ? p * 2 : (p - 0.5) * 2; + p = p < 0.5f ? p * 2 : (p - 0.5f) * 2; float v = LightTransformer::smoothed_progress(p); return LightColorValues::lerp(start, end, v); diff --git a/esphome/components/lightwaverf/lightwaverf.h b/esphome/components/lightwaverf/lightwaverf.h index 224da6315f..36dac3c86f 100644 --- a/esphome/components/lightwaverf/lightwaverf.h +++ b/esphome/components/lightwaverf/lightwaverf.h @@ -15,7 +15,7 @@ namespace esphome::lightwaverf { #ifdef USE_ESP8266 -class LightWaveRF : public PollingComponent { +class LightWaveRF final : public PollingComponent { public: void set_pin(InternalGPIOPin *pin_tx, InternalGPIOPin *pin_rx) { pin_tx_ = pin_tx; @@ -37,7 +37,7 @@ class LightWaveRF : public PollingComponent { LwTx lwtx_; }; -template class SendRawAction : public Action { +template class SendRawAction final : public Action { public: SendRawAction(LightWaveRF *parent) : parent_(parent){}; TEMPLATABLE_VALUE(int, repeat); diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h index 8b345515ab..ad82e6c3a0 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.h @@ -12,7 +12,7 @@ namespace esphome::lilygo_t5_47 { using namespace touchscreen; -class LilygoT547Touchscreen : public Touchscreen, public i2c::I2CDevice { +class LilygoT547Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/lm75b/lm75b.h b/esphome/components/lm75b/lm75b.h index eaf1b46550..3d5b97ae5b 100644 --- a/esphome/components/lm75b/lm75b.h +++ b/esphome/components/lm75b/lm75b.h @@ -8,7 +8,7 @@ namespace esphome::lm75b { static const uint8_t LM75B_REG_TEMPERATURE = 0x00; -class LM75BComponent : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class LM75BComponent final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/ln882x/boards.py b/esphome/components/ln882x/boards.py index df44419ed2..bcd3ffbd9e 100644 --- a/esphome/components/ln882x/boards.py +++ b/esphome/components/ln882x/boards.py @@ -15,26 +15,38 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_LN882H LN882X_BOARDS = { - "generic-ln882hki": { - "name": "Generic - LN882HKI", + "generic-ln882h": { + "name": "Generic - LN882H", "family": FAMILY_LN882H, }, - "wb02a": { - "name": "WB02A Wi-Fi/BLE Module", - "family": FAMILY_LN882H, - }, - "wl2s": { - "name": "WL2S Wi-Fi/BLE Module", + "generic-ln882h-tuya": { + "name": "Generic - LN882H (Tuya)", "family": FAMILY_LN882H, }, "ln-02": { "name": "LN-02 Wi-Fi/BLE Module", "family": FAMILY_LN882H, }, + "ln-cb3s-v1.0": { + "name": "LN-CB3S V1.0", + "family": FAMILY_LN882H, + }, + "wb02a": { + "name": "WB02A Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2h-u": { + "name": "WL2H-U Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, + "wl2s": { + "name": "WL2S Wi-Fi/BLE Module", + "family": FAMILY_LN882H, + }, } LN882X_BOARD_PINS = { - "generic-ln882hki": { + "generic-ln882h": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, "WIRE0_SCL_2": 2, @@ -153,27 +165,292 @@ LN882X_BOARD_PINS = { "A6": 20, "A7": 21, }, + "generic-ln882h-tuya": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 8, + "WIRE0_SCL_9": 9, + "WIRE0_SCL_10": 10, + "WIRE0_SCL_11": 11, + "WIRE0_SCL_12": 12, + "WIRE0_SCL_13": 19, + "WIRE0_SCL_14": 20, + "WIRE0_SCL_15": 21, + "WIRE0_SCL_16": 22, + "WIRE0_SCL_17": 23, + "WIRE0_SCL_18": 24, + "WIRE0_SCL_19": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 8, + "WIRE0_SDA_9": 9, + "WIRE0_SDA_10": 10, + "WIRE0_SDA_11": 11, + "WIRE0_SDA_12": 12, + "WIRE0_SDA_13": 19, + "WIRE0_SDA_14": 20, + "WIRE0_SDA_15": 21, + "WIRE0_SDA_16": 22, + "WIRE0_SDA_17": 23, + "WIRE0_SDA_18": 24, + "WIRE0_SDA_19": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 5, + "D6": 6, + "D7": 7, + "D8": 8, + "D9": 9, + "D10": 10, + "D11": 11, + "D12": 12, + "D13": 19, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "D18": 24, + "D19": 25, + "A2": 0, + "A3": 1, + "A4": 4, + "A5": 19, + "A6": 20, + "A7": 21, + }, + "ln-02": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 9, + "WIRE0_SCL_5": 11, + "WIRE0_SCL_6": 19, + "WIRE0_SCL_7": 24, + "WIRE0_SCL_8": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 9, + "WIRE0_SDA_5": 11, + "WIRE0_SDA_6": 19, + "WIRE0_SDA_7": 24, + "WIRE0_SDA_8": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC5": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB03": 19, + "PB3": 19, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "SCL0": 9, + "SDA0": 9, + "TX0": 2, + "TX1": 25, + "D0": 11, + "D1": 19, + "D2": 3, + "D3": 24, + "D4": 2, + "D5": 25, + "D6": 1, + "D7": 0, + "D8": 9, + "A0": 19, + "A1": 1, + "A2": 0, + }, + "ln-cb3s-v1.0": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 11, + "WIRE0_SCL_9": 20, + "WIRE0_SCL_10": 21, + "WIRE0_SCL_11": 22, + "WIRE0_SCL_12": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 11, + "WIRE0_SDA_9": 20, + "WIRE0_SDA_10": 21, + "WIRE0_SDA_11": 22, + "WIRE0_SDA_12": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA09": 9, + "PA9": 9, + "PA11": 11, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "TX0": 2, + "TX1": 25, + "D0": 0, + "D1": 1, + "D2": 4, + "D3": 5, + "D4": 6, + "D5": 20, + "D6": 25, + "D7": 9, + "D8": 21, + "D9": 22, + "D10": 3, + "D11": 2, + "D12": 11, + "A0": 0, + "A1": 1, + "A2": 4, + "A3": 20, + "A4": 21, + }, "wb02a": { "WIRE0_SCL_0": 1, "WIRE0_SCL_1": 2, "WIRE0_SCL_2": 3, "WIRE0_SCL_3": 4, "WIRE0_SCL_4": 5, - "WIRE0_SCL_5": 7, - "WIRE0_SCL_6": 9, - "WIRE0_SCL_7": 10, - "WIRE0_SCL_8": 24, - "WIRE0_SCL_9": 25, + "WIRE0_SCL_5": 6, + "WIRE0_SCL_6": 7, + "WIRE0_SCL_7": 9, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 24, + "WIRE0_SCL_10": 25, "WIRE0_SDA_0": 1, "WIRE0_SDA_1": 2, "WIRE0_SDA_2": 3, "WIRE0_SDA_3": 4, "WIRE0_SDA_4": 5, - "WIRE0_SDA_5": 7, - "WIRE0_SDA_6": 9, - "WIRE0_SDA_7": 10, - "WIRE0_SDA_8": 24, - "WIRE0_SDA_9": 25, + "WIRE0_SDA_5": 6, + "WIRE0_SDA_6": 7, + "WIRE0_SDA_7": 9, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 24, + "WIRE0_SDA_10": 25, "SERIAL0_RX": 3, "SERIAL0_TX": 2, "SERIAL1_RX": 24, @@ -190,6 +467,8 @@ LN882X_BOARD_PINS = { "PA4": 4, "PA05": 5, "PA5": 5, + "PA06": 6, + "PA6": 6, "PA07": 7, "PA7": 7, "PA09": 9, @@ -206,18 +485,128 @@ LN882X_BOARD_PINS = { "TX0": 2, "TX1": 25, "D0": 7, - "D1": 5, + "D1": 6, "D2": 3, "D3": 10, "D4": 2, "D5": 1, "D6": 4, - "D7": 9, - "D8": 24, - "D9": 25, + "D7": 5, + "D8": 9, + "D9": 24, + "D10": 25, "A0": 1, "A1": 4, }, + "wl2h-u": { + "WIRE0_SCL_0": 0, + "WIRE0_SCL_1": 1, + "WIRE0_SCL_2": 2, + "WIRE0_SCL_3": 3, + "WIRE0_SCL_4": 4, + "WIRE0_SCL_5": 5, + "WIRE0_SCL_6": 6, + "WIRE0_SCL_7": 7, + "WIRE0_SCL_8": 10, + "WIRE0_SCL_9": 11, + "WIRE0_SCL_10": 12, + "WIRE0_SCL_11": 19, + "WIRE0_SCL_12": 20, + "WIRE0_SCL_13": 21, + "WIRE0_SCL_14": 22, + "WIRE0_SCL_15": 23, + "WIRE0_SCL_16": 24, + "WIRE0_SCL_17": 25, + "WIRE0_SDA_0": 0, + "WIRE0_SDA_1": 1, + "WIRE0_SDA_2": 2, + "WIRE0_SDA_3": 3, + "WIRE0_SDA_4": 4, + "WIRE0_SDA_5": 5, + "WIRE0_SDA_6": 6, + "WIRE0_SDA_7": 7, + "WIRE0_SDA_8": 10, + "WIRE0_SDA_9": 11, + "WIRE0_SDA_10": 12, + "WIRE0_SDA_11": 19, + "WIRE0_SDA_12": 20, + "WIRE0_SDA_13": 21, + "WIRE0_SDA_14": 22, + "WIRE0_SDA_15": 23, + "WIRE0_SDA_16": 24, + "WIRE0_SDA_17": 25, + "SERIAL0_RX": 3, + "SERIAL0_TX": 2, + "SERIAL1_RX": 24, + "SERIAL1_TX": 25, + "ADC2": 0, + "ADC3": 1, + "ADC4": 4, + "ADC5": 19, + "ADC6": 20, + "ADC7": 21, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PB03": 19, + "PB3": 19, + "PB04": 20, + "PB4": 20, + "PB05": 21, + "PB5": 21, + "PB06": 22, + "PB6": 22, + "PB07": 23, + "PB7": 23, + "PB08": 24, + "PB8": 24, + "PB09": 25, + "PB9": 25, + "RX0": 3, + "RX1": 24, + "TX0": 2, + "TX1": 25, + "D0": 5, + "D1": 6, + "D2": 4, + "D3": 1, + "D4": 0, + "D5": 24, + "D6": 25, + "D7": 7, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 19, + "D12": 2, + "D13": 3, + "D14": 20, + "D15": 21, + "D16": 22, + "D17": 23, + "A0": 4, + "A1": 1, + "A2": 0, + "A3": 19, + "A4": 20, + "A5": 21, + }, "wl2s": { "WIRE0_SCL_0": 0, "WIRE0_SCL_1": 1, @@ -298,68 +687,6 @@ LN882X_BOARD_PINS = { "A1": 19, "A2": 1, }, - "ln-02": { - "WIRE0_SCL_0": 0, - "WIRE0_SCL_1": 1, - "WIRE0_SCL_2": 2, - "WIRE0_SCL_3": 3, - "WIRE0_SCL_4": 9, - "WIRE0_SCL_5": 11, - "WIRE0_SCL_6": 19, - "WIRE0_SCL_7": 24, - "WIRE0_SCL_8": 25, - "WIRE0_SDA_0": 0, - "WIRE0_SDA_1": 1, - "WIRE0_SDA_2": 2, - "WIRE0_SDA_3": 3, - "WIRE0_SDA_4": 9, - "WIRE0_SDA_5": 11, - "WIRE0_SDA_6": 19, - "WIRE0_SDA_7": 24, - "WIRE0_SDA_8": 25, - "SERIAL0_RX": 3, - "SERIAL0_TX": 2, - "SERIAL1_RX": 24, - "SERIAL1_TX": 25, - "ADC2": 0, - "ADC3": 1, - "ADC5": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA09": 9, - "PA9": 9, - "PA11": 11, - "PB03": 19, - "PB3": 19, - "PB08": 24, - "PB8": 24, - "PB09": 25, - "PB9": 25, - "RX0": 3, - "RX1": 24, - "SCL0": 9, - "SDA0": 9, - "TX0": 2, - "TX1": 25, - "D0": 11, - "D1": 19, - "D2": 3, - "D3": 24, - "D4": 2, - "D5": 25, - "D6": 1, - "D7": 0, - "D8": 9, - "A0": 19, - "A1": 1, - "A2": 0, - }, } BOARDS = LN882X_BOARDS diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index c140bc568f..ec6ead79f3 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::lock { -template class LockAction : public Action { +template class LockAction final : public Action { public: explicit LockAction(Lock *a_lock) : lock_(a_lock) {} @@ -16,7 +16,7 @@ template class LockAction : public Action { Lock *lock_; }; -template class UnlockAction : public Action { +template class UnlockAction final : public Action { public: explicit UnlockAction(Lock *a_lock) : lock_(a_lock) {} @@ -26,7 +26,7 @@ template class UnlockAction : public Action { Lock *lock_; }; -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Lock *a_lock) : lock_(a_lock) {} @@ -36,7 +36,7 @@ template class OpenAction : public Action { Lock *lock_; }; -template class LockCondition : public Condition { +template class LockCondition final : public Condition { public: LockCondition(Lock *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 9629dce0bf..77a875dd8f 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -154,7 +154,7 @@ HARDWARE_UART_TO_SERIAL = { UART2: cg.global_ns.Serial2, DEFAULT: cg.global_ns.Serial, }, - PLATFORM_RP2040: { + PLATFORM_RP2: { UART0: cg.global_ns.Serial1, UART1: cg.global_ns.Serial2, USB_CDC: cg.global_ns.Serial, @@ -171,7 +171,7 @@ def uart_selection(value): return cv.one_of(*UART_SELECTION_ESP32[variant], upper=True)(value) if CORE.is_esp8266: return cv.one_of(*UART_SELECTION_ESP8266, upper=True)(value) - if CORE.is_rp2040: + if CORE.is_rp2: return cv.one_of(*UART_SELECTION_RP2040, upper=True)(value) if CORE.is_libretiny: family = get_libretiny_family() @@ -282,7 +282,7 @@ CONFIG_SCHEMA = cv.All( esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, esp32_s31=USB_SERIAL_JTAG, - rp2040=USB_CDC, + rp2=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, rtl87xx=DEFAULT, @@ -292,7 +292,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP8266, PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, @@ -417,11 +417,7 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_ESP8266_LOGGER_SERIAL1") enable_serial1() - if ( - (CORE.is_esp8266 or CORE.is_rp2040) - and has_serial_logging - and is_at_least_verbose - ): + if (CORE.is_esp8266 or CORE.is_rp2) and has_serial_logging and is_at_least_verbose: debug_serial_port = HARDWARE_UART_TO_SERIAL[CORE.target_platform][ config.get(CONF_HARDWARE_UART) ] @@ -605,7 +601,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "logger_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "logger_host.cpp": {PlatformFramework.HOST_NATIVE}, - "logger_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "logger_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "logger_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 684da0202e..6527b6aa8c 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -206,7 +206,7 @@ void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) UARTSelection Logger::get_uart() const { return this->uart_; } #endif diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 784cbea67e..69d8e6d32a 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -23,10 +23,10 @@ #if defined(USE_ESP8266) #include #endif // USE_ESP8266 -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO #ifdef USE_ESP32 @@ -96,7 +96,7 @@ struct CStrCompare { // macOS allows up to 64 bytes, Linux up to 16 static constexpr size_t THREAD_NAME_BUF_SIZE = 64; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * * Advanced configuration (pin selection, etc) is not supported. @@ -122,7 +122,7 @@ enum UARTSelection : uint8_t { UART_SELECTION_UART0_SWAP, #endif // USE_ESP8266 }; -#endif // USE_ESP32 || USE_ESP8266 || USE_RP2040 || USE_LIBRETINY || USE_ZEPHYR +#endif // USE_ESP32 || USE_ESP8266 || USE_RP2 || USE_LIBRETINY || USE_ZEPHYR /** * @brief Logger component for all ESPHome logging. @@ -160,7 +160,7 @@ class Logger final : public Component { #ifdef USE_HOST void create_pthread_key() { pthread_key_create(&log_recursion_key_, nullptr); } #endif -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; } /// Get the UART used by the logger. UARTSelection get_uart() const; @@ -351,7 +351,7 @@ class Logger final : public Component { #endif // Group smaller types together at the end uint8_t current_level_{ESPHOME_LOG_LEVEL_VERY_VERBOSE}; -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) UARTSelection uart_{UART_SELECTION_UART0}; #endif #ifdef USE_LIBRETINY @@ -505,8 +505,8 @@ class LoggerMessageTrigger final : public Triggerbaud_rate_ > 0) { static const struct device *uart_dev = nullptr; switch (this->uart_) { - case UART_SELECTION_UART0: + case UART_SELECTION_UART0: // NOLINT(bugprone-branch-clone) uart_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(uart0)); break; case UART_SELECTION_UART1: diff --git a/esphome/components/lps22/lps22.h b/esphome/components/lps22/lps22.h index c6746f2343..020c14296c 100644 --- a/esphome/components/lps22/lps22.h +++ b/esphome/components/lps22/lps22.h @@ -6,7 +6,7 @@ namespace esphome::lps22 { -class LPS22Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class LPS22Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h index 75462ff1fb..47d6f55939 100644 --- a/esphome/components/lsm6ds/lsm6ds.h +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -82,7 +82,7 @@ enum LSM6DSGyroODR : uint8_t { }; // ── Main component class ───────────────────────────────────────────────────── -class LSM6DSComponent : public motion::MotionComponent, public i2c::I2CDevice { +class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index 62a0d2290a..dd78b20f2c 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -75,7 +75,7 @@ void LTR390Component::read_als_() { uint32_t als = *val; if (this->light_sensor_ != nullptr) { - float lux = ((0.6 * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; + float lux = ((0.6f * als) / (GAINVALUES[this->gain_als_] * RESOLUTIONVALUE[this->res_als_])) * this->wfac_; this->light_sensor_->publish_state(lux); } diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 1ead84b4a8..1e3b6494bb 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -39,7 +39,7 @@ enum LTR390RESOLUTION { LTR390_RESOLUTION_13BIT, }; -class LTR390Component : public PollingComponent, public i2c::I2CDevice { +class LTR390Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index 9cba06e483..afdc271167 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -500,12 +500,12 @@ void LTRAlsPs501Component::apply_lux_calculation_(AlsReadings &data) { // method from // https://github.com/fards/Ainol_fire_kernel/blob/83832cf8a3082fd8e963230f4b1984479d1f1a84/customer/drivers/lightsensor/ltr501als.c#L295 - if (ratio < 0.45) { - lux = 1.7743 * ch0 + 1.1059 * ch1; - } else if (ratio < 0.64) { - lux = 3.7725 * ch0 - 1.3363 * ch1; - } else if (ratio < 0.85) { - lux = 1.6903 * ch0 - 0.1693 * ch1; + if (ratio < 0.45f) { + lux = 1.7743f * ch0 + 1.1059f * ch1; + } else if (ratio < 0.64f) { + lux = 3.7725f * ch0 - 1.3363f * ch1; + } else if (ratio < 0.85f) { + lux = 1.6903f * ch0 - 0.1693f * ch1; } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index c7eccbeea9..d1f7648d4c 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -19,7 +19,7 @@ enum LtrType : uint8_t { LTR_TYPE_ALS_AND_PS = 3, }; -class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { +class LTRAlsPs501Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index cca9330e76..c1fa9009b3 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -15,7 +15,7 @@ from esphome.const import ( CONF_NAME, CONF_REPEAT, CONF_TYPE, - DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_5, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_PROXIMITY, accuracy_decimals=0, - device_class=DEVICE_CLASS_DISTANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All( sensor.sensor_schema( icon=ICON_GAIN, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index b7fad2e876..0d43aac20e 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -480,12 +480,12 @@ void LTRAlsPsComponent::apply_lux_calculation_(AlsReadings &data) { float inv_pfactor = this->glass_attenuation_factor_; float lux = 0.0f; - if (ratio < 0.45) { - lux = (1.7743 * ch0 + 1.1059 * ch1); - } else if (ratio < 0.64 && ratio >= 0.45) { - lux = (4.2785 * ch0 - 1.9548 * ch1); - } else if (ratio < 0.85 && ratio >= 0.64) { - lux = (0.5926 * ch0 + 0.1185 * ch1); + if (ratio < 0.45f) { + lux = (1.7743f * ch0 + 1.1059f * ch1); + } else if (ratio < 0.64f && ratio >= 0.45f) { + lux = (4.2785f * ch0 - 1.9548f * ch1); + } else if (ratio < 0.85f && ratio >= 0.64f) { + lux = (0.5926f * ch0 + 0.1185f * ch1); } else { ESP_LOGW(TAG, "Impossible ch1/(ch0 + ch1) ratio"); lux = 0.0f; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9137412abe..256bf4bb3a 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -52,9 +52,11 @@ from esphome.writer import clean_build from esphome.yaml_util import load_yaml from . import defines as df, lv_validation as lvalid, widgets +from .animation import ANIMATION_SCHEMA, add_animation_triggers, animations_to_code from .automation import layers_to_code, lvgl_update from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, + CONF_ANIMATIONS, LOGGER, add_lv_use, get_focused_widgets, @@ -146,6 +148,8 @@ SIMPLE_TRIGGERS = ( df.CONF_ON_RESUME, df.CONF_ON_DRAW_START, df.CONF_ON_DRAW_END, + df.CONF_ON_LANDSCAPE, + df.CONF_ON_PORTRAIT, ) @@ -414,6 +418,10 @@ async def to_code(configs): await cg.register_component(lv_component, config) if rotation := config.get(CONF_ROTATION): cg.add(lv_component.set_rotation(rotation)) + if paused := config[df.CONF_PAUSED]: + cg.add(lv_component.set_paused(paused, False)) + if refr_time := config.get(df.CONF_REFRESH_INTERVAL): + cg.add(lv_component.set_refresh_interval(refr_time.total_milliseconds)) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) lv_scr_act = get_screen_active(lv_component) @@ -431,7 +439,8 @@ async def to_code(configs): await layers_to_code(lv_component, config) await lvgl_update(lv_component, config) await msgboxes_to_code(lv_component, config) - # await disp_update(lv_component.get_disp(), config) + await animations_to_code(config.get(CONF_ANIMATIONS, [])) + # Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed. set_widgets_completed(True) async with LvContext(): @@ -439,6 +448,7 @@ async def to_code(configs): await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) + await add_animation_triggers(config.get(CONF_ANIMATIONS, [])) await generate_page_triggers(config) await initial_focus_to_code(config) for conf in config.get(CONF_ON_IDLE, ()): @@ -598,6 +608,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( 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(df.CONF_REFRESH_INTERVAL): cv.positive_time_period_milliseconds, 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, @@ -631,6 +642,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( for x in SIMPLE_TRIGGERS }, cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_ANIMATIONS): cv.ensure_list(ANIMATION_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), @@ -642,6 +654,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( 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, + cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, } ) .extend(DISP_BG_SCHEMA) diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h new file mode 100644 index 0000000000..1e0abce358 --- /dev/null +++ b/esphome/components/lvgl/animation.h @@ -0,0 +1,197 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_LVGL_ANIMATION +#include "lvgl_esphome.h" +#include "esphome/core/hal.h" + +namespace esphome::lvgl { + +enum class AnimationState { + STOPPED, + STARTED, + RUNNING, +}; + +class LvAnimationTiming { + public: + // Map progress in the range [0, 1] + virtual float map_progress(float value) = 0; +}; + +class LvAnimationTimingRoundTrip : public LvAnimationTiming { + public: + float map_progress(float value) override { + value *= 2.0f; + if (value > 1.0f) + return 2.0f - value; + return value; + } +}; + +class LvAnimationTimingGravity : public LvAnimationTiming { + public: + LvAnimationTimingGravity(float acceleration, float bounce) : acceleration_(acceleration), bounce_(bounce) {} + float map_progress(float value) override { + if (value == 0.0f) { + this->initial_position_ = 0.0f; + this->initial_speed_ = 0.0f; + this->initial_time_ = 0.0f; + } + auto position = this->calc_pos_(value); + if (position > 1.0f) { + auto initial_time = this->calc_end_time_(); + this->initial_speed_ = -this->calc_speed_(initial_time) * this->bounce_; + this->initial_position_ = 1.0f; + this->initial_time_ = initial_time; + position = calc_pos_(value); + if (position > 1.0f) { + position = 1.0f; + } + } + return position; + } + + protected: + float calc_pos_(float value) const { + value -= this->initial_time_; + return (0.5 * value * this->acceleration_ + this->initial_speed_) * value + this->initial_position_; + } + + float calc_speed_(float value) const { + value -= this->initial_time_; + return this->acceleration_ * value + this->initial_speed_; + } + + float calc_end_time_() const { + return (-this->initial_speed_ + std::sqrt(this->initial_speed_ * this->initial_speed_ - + 4.0f * this->acceleration_ / 2.0 * (this->initial_position_ - 1.0f))) / + this->acceleration_ + + this->initial_time_; + } + + float acceleration_; + float bounce_; + float initial_position_{0.0f}; + float initial_time_{0.0f}; + float initial_speed_{0.0f}; +}; + +class LvAnimationTimingEaseInOut : public LvAnimationTiming { + public: + LvAnimationTimingEaseInOut(float slope) : slope_(slope) {} + float map_progress(float value) override { + float sqr = value * value; + sqr = sqr / (2.0f * (sqr - value) + 1.0f); + return this->slope_ * sqr + (1.0 - this->slope_) * value; + } + + protected: + float slope_; +}; + +template class LvAnimation : public Component { + public: + LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector> from, + std::vector> to) + : update_callback_(update_callback) { + std::copy(from.begin(), from.end(), this->from_); + std::copy(to.begin(), to.end(), this->to_); + } + + void start() { + if (this->state_ > AnimationState::STOPPED) + this->stop(); + if (this->duration_ == 0) + return; + // evaluate any lambdas + for (size_t i = 0; i != DATA_SIZE; i++) { + this->data_from_[i] = this->from_[i].value(); + this->data_to_[i] = this->to_[i].value(); + } + this->start_time_ = millis(); + this->state_ = AnimationState::STARTED; + this->loop(); + this->start_callback_.call(); + } + + void stop() { + // Only fire the stop callback on a genuine running -> stopped transition, so that + // repeated stop() calls (e.g. start() pre-clearing a stopped animation) don't re-fire it. + if (this->state_ == AnimationState::STOPPED) + return; + this->state_ = AnimationState::STOPPED; + this->stop_callback_.call(); + } + + void setup() override { + if constexpr (AUTO_START) + this->start(); + } + + void loop() override { + if (this->state_ == AnimationState::STOPPED) + return; + uint32_t elapsed = millis() - this->start_time_; + float progress = static_cast(elapsed) / static_cast(this->duration_); + switch (this->state_) { + case AnimationState::STARTED: + if (elapsed < this->start_delay_) + return; + this->state_ = AnimationState::RUNNING; + this->start_time_ = millis(); + progress = 0.0f; + break; + case AnimationState::RUNNING: + if (progress >= 1.0f) { + progress = 1.0f; + this->stop(); + if (this->loop_) + this->start(); + } + break; + default: + return; + } + + for (auto *timing : this->timings_) { + progress = timing->map_progress(progress); + } + lv_coord_t data[DATA_SIZE]; + for (size_t i = 0; i != DATA_SIZE; i++) { + data[i] = static_cast( + roundf(this->data_from_[i] + static_cast(this->data_to_[i] - this->data_from_[i]) * progress)); + } + this->update_callback_(data); + } + + float get_setup_priority() const override { return setup_priority::PROCESSOR - 20.0; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + void set_start_delay(uint32_t start_delay) { this->start_delay_ = start_delay; } + void add_timing(LvAnimationTiming *timing) { this->timings_.push_back(timing); } + void set_loop(bool loop) { this->loop_ = loop; } + + template void add_on_start_callback(F &&callback) { + this->start_callback_.add(std::forward(callback)); + } + template void add_on_stop_callback(F &&callback) { this->stop_callback_.add(std::forward(callback)); } + + protected: + void (*const update_callback_)(const lv_coord_t *data); + LazyCallbackManager start_callback_{}; + LazyCallbackManager stop_callback_{}; + TemplatableValue from_[DATA_SIZE]{}; + TemplatableValue to_[DATA_SIZE]{}; + uint32_t duration_{0}; + uint32_t start_delay_{0}; + uint32_t start_time_{0}; + lv_coord_t data_from_[DATA_SIZE]{0}; + lv_coord_t data_to_[DATA_SIZE]{0}; + AnimationState state_{AnimationState::STOPPED}; + std::vector timings_{}; + bool loop_{false}; +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_ANIMATION diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py new file mode 100644 index 0000000000..2b1500f2c4 --- /dev/null +++ b/esphome/components/lvgl/animation.py @@ -0,0 +1,295 @@ +from esphome import automation, codegen as cg, config_validation as cv +from esphome.automation import Trigger, build_automation +from esphome.config_validation import COMPONENT_SCHEMA +from esphome.const import ( + CONF_ACCELERATION, + CONF_DURATION, + CONF_FROM, + CONF_ID, + CONF_ON_START, + CONF_TIMING, + CONF_TO, + CONF_TRIGGER_ID, + CONF_TYPE, + CONF_WEIGHT, +) +from esphome.cpp_generator import MockObj, TemplateArguments + +from ..const import CONF_LOOP +from .defines import ( + CONF_AUTO_START, + CONF_LVGL_ID, + CONF_ON_STOP, + CONF_WIDGETS, + LValidator, + add_define, + literal, +) +from .lv_validation import ( + color, + get_component_colors, + lv_color, + lv_milliseconds, + lv_positive_float, + lv_zero_to_one_float, +) +from .lvcode import LVGL_COMP_ARG, LambdaContext, LvglComponent, lv_add +from .schemas import STYLE_PROPS +from .types import LvAnimation, LvglAction, lv_color_t, lv_coord_t, lv_obj_t, lvgl_ns +from .widgets import get_widgets + +LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") +LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") + +CONF_BOUNCE = "bounce" + + +def timing_class(name, extras=None): + # Convert config option to camel case + cls_name = "LvAnimationTiming" + "".join([w.capitalize() for w in name.split("_")]) + cls = lvgl_ns.class_(cls_name) + schema = cv.Schema({cv.GenerateID(): cv.declare_id(cls)}) + if extras: + schema = schema.extend(extras) + return name, schema + + +# TODO - currently the order of arguments to timing classes is expected to be alphabetical, but this is not enforced. +# It would be better to have a more robust way of passing arguments to the timing classes. +TIMING_SCHEMA = cv.maybe_simple_value( + cv.typed_schema( + dict( + [ + timing_class("round_trip"), + timing_class( + "ease_in_out", + {cv.Optional(CONF_WEIGHT, default=2.0): lv_positive_float}, + ), + timing_class( + "gravity", + { + cv.Optional(CONF_ACCELERATION, default=0.5): lv_positive_float, + cv.Optional(CONF_BOUNCE, default=0.5): lv_zero_to_one_float, + }, + ), + ] + ), + default_type="ease_in_out", + ), + key=CONF_TYPE, +) + +CONF_START_DELAY = "start_delay" + + +class LiteralColorValidator(LValidator): + def __init__(self): + super().__init__( + color, lv_color_t, retmapper=get_component_colors, animatable=True + ) + + def __call__(self, value): + if isinstance(value, cv.Lambda): + raise cv.Invalid( + "An animated color may not be set with a lambda, only a literal color value." + ) + return super().__call__(value) + + +literal_color = LiteralColorValidator() + + +def from_to(validator): + return cv.Schema( + { + cv.Required(CONF_FROM): validator, + cv.Required(CONF_TO): validator, + } + ) + + +# Colors can only be animated between constants, not lambdas. +def map_v(validator): + if validator == lv_color: + return literal_color + return validator + + +ANIMABLE_STYLES = { + k: map_v(v) + for k, v in STYLE_PROPS.items() + if isinstance(v, LValidator) and v.animatable +} + +ANIMATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_AUTO_START, default=False): cv.boolean, + cv.Optional(CONF_LOOP, default=False): cv.boolean, + cv.Optional(CONF_DURATION, default="5s"): lv_milliseconds, + cv.Optional(CONF_START_DELAY, default="0s"): lv_milliseconds, + cv.Optional(CONF_TIMING, default=[]): cv.ensure_list(TIMING_SCHEMA), + cv.Required(CONF_ID): cv.declare_id(LvAnimation), + cv.Optional(CONF_ON_START): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Optional(CONF_ON_STOP): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Trigger.template()), + } + ), + cv.Required(CONF_WIDGETS): cv.ensure_list( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_obj_t), + } + ).extend({cv.Optional(k): from_to(v) for k, v in ANIMABLE_STYLES.items()}) + ), + } +).extend(COMPONENT_SCHEMA) + + +async def _process_arg(validator, arg) -> list: + # from/to values are evaluated at animation start with no arguments, so the + # generated lambda must be parameterless rather than inheriting the enclosing + # update-callback's `values` parameter. + value = await validator.process(arg, args=[], raw_lambda=True) + value = list(value) if isinstance(value, tuple) else [value] + return [literal(f"TemplatableValue({v})") for v in value] + + +async def animations_to_code(config): + for animation in config: + add_define("USE_LVGL_ANIMATION") + widgets = animation[CONF_WIDGETS] + async with LambdaContext( + [(lv_coord_t.operator("const").operator("ptr"), "values")] + ) as ctx: + froms = [] + tos = [] + for widget in widgets: + w = (await get_widgets(widget))[0] + props = [(k, v) for k, v in widget.items() if k in ANIMABLE_STYLES] + for prop, value_range in props: + # prop is the style property, value_range is a dict with from: and to: values + validator = ANIMABLE_STYLES[prop] + from_value = await _process_arg(validator, value_range[CONF_FROM]) + to_value = await _process_arg(validator, value_range[CONF_TO]) + index = len(froms) + if len(from_value) == 1: + value = f"values[{index}]" + else: + value = f"lv_color_make(values[{index}+0], values[{index}+1], values[{index}+2])" + w.set_style(prop, literal(value), 0) + # The value arrays are extended by 1 item for scalar properties, 3 for colors + froms.extend(from_value) + tos.extend(to_value) + + data_size = len(froms) + loop = animation[CONF_LOOP] + start_delay = await lv_milliseconds.process(animation.get(CONF_START_DELAY)) + var = cg.new_Pvariable( + animation[CONF_ID], + TemplateArguments(data_size, animation[CONF_AUTO_START]), + await ctx.get_lambda(), + froms, + tos, + ) + for timing in animation[CONF_TIMING]: + timing_id = timing[CONF_ID] + args = sorted( + [(k, v) for k, v in timing.items() if k not in [CONF_ID, CONF_TYPE]] + ) + args = [v for k, v in args] + timing_var = cg.new_Pvariable(timing_id, *args) + cg.add(var.add_timing(timing_var)) + + if start_delay: + cg.add(var.set_start_delay(start_delay)) + if loop: + cg.add(var.set_loop(loop)) + cg.add( + var.set_duration(await lv_milliseconds.process(animation[CONF_DURATION])) + ) + await cg.register_component(var, animation) + + +async def add_animation_triggers(config): + async def add_triggers(animation: MockObj, event: str, config: dict) -> None: + for conf in config: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await build_automation(trigger, [], conf) + async with LambdaContext([]) as context: + lv_add(trigger.trigger()) + lv_add( + getattr( + animation, + f"add_{event}_callback", + )(await context.get_lambda()) + ) + + for animation in config: + var = await cg.get_variable(animation[CONF_ID]) + await add_triggers(var, CONF_ON_START, animation.get(CONF_ON_START, [])) + await add_triggers(var, CONF_ON_STOP, animation.get(CONF_ON_STOP, [])) + + +@automation.register_action( + "lvgl.animation.start", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + cv.Optional(CONF_DURATION): lv_milliseconds, + cv.Optional(CONF_START_DELAY): lv_milliseconds, + cv.Optional(CONF_LOOP): cv.boolean, + }, + key=CONF_ID, + ), + synchronous=True, +) +async def start_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + loop = config.get(CONF_LOOP) + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + if loop is not None: + context.add(anim_var.set_loop(loop)) + if (duration := config.get(CONF_DURATION)) is not None: + context.add( + anim_var.set_duration(await lv_milliseconds.process(duration)) + ) + if (start_delay := config.get(CONF_START_DELAY)) is not None: + context.add( + anim_var.set_start_delay(await lv_milliseconds.process(start_delay)) + ) + context.add(anim_var.start()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var + + +@automation.register_action( + "lvgl.animation.stop", + LvglAction, + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.ensure_list(cv.use_id(LvAnimation)), + cv.GenerateID(CONF_LVGL_ID): cv.use_id(LvglComponent), + }, + key=CONF_ID, + ), + synchronous=True, +) +async def stop_animation(config, action_id, template_arg, args): + animations = config[CONF_ID] + async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + for animation in animations: + anim_var = await cg.get_variable(animation) + context.add(anim_var.stop()) + var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) + await cg.register_parented(var, config[CONF_LVGL_ID]) + return var diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index bf9a3d74ad..b7c90a5c51 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -4,7 +4,6 @@ from typing import Any from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg -from esphome.components.display import validate_rotation import esphome.config_validation as cv from esphome.const import CONF_ACTION, CONF_GROUP, CONF_ID, CONF_ROTATION, CONF_TIMEOUT from esphome.core import Lambda @@ -16,6 +15,7 @@ from .defines import ( CONF_BOTTOM_LAYER, CONF_EDITING, CONF_FREEZE, + CONF_LAYOUT, CONF_LVGL_ID, CONF_MAIN, CONF_OBJ, @@ -29,7 +29,8 @@ from .defines import ( get_options, get_refreshed_widgets, ) -from .lv_validation import lv_bool, lv_milliseconds +from .layout import layout_validator +from .lv_validation import lv_bool, lv_milliseconds, lv_rotation from .lvcode import ( LVGL_COMP_ARG, UPDATE_EVENT, @@ -199,7 +200,7 @@ async def lvgl_is_idle(config, condition_id, template_arg, args): def _validate_rotation(value): # Note that we need rotation get_options()[CONF_ROTATION] = True - return validate_rotation(value) + return lv_rotation(value) @automation.register_action( @@ -218,7 +219,8 @@ def _validate_rotation(value): async def lvgl_set_rotation(config, action_id, template_arg, args): lv_comp = await cg.get_variable(config[CONF_LVGL_ID]) async with LambdaContext(args, where=action_id) as context: - lv_add(lv_comp.set_rotation(config[CONF_ROTATION])) + rotation = await lv_rotation.process(config[CONF_ROTATION]) + lv_add(lv_comp.set_rotation(rotation)) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) @@ -254,6 +256,13 @@ layer_spec = WidgetType(CONF_OBJ, lv_obj_t, (CONF_MAIN, CONF_SCROLLBAR), is_mock DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} +def _layer_update_schema() -> cv.Schema: + """Schema for updating a display layer's styling and layout options.""" + return part_schema(layer_spec.parts).extend( + {cv.Optional(CONF_LAYOUT): layout_validator} + ) + + @automation.register_action( "lvgl.update", LvglAction, @@ -262,8 +271,9 @@ DISP_PROPS = {str(x) for x in DISP_BG_SCHEMA.schema} .extend(DISP_BG_SCHEMA) .extend( { - cv.Optional(CONF_TOP_LAYER): part_schema(layer_spec.parts), - cv.Optional(CONF_BOTTOM_LAYER): part_schema(layer_spec.parts), + cv.Optional(CONF_LAYOUT): layout_validator, + cv.Optional(CONF_TOP_LAYER): _layer_update_schema(), + cv.Optional(CONF_BOTTOM_LAYER): _layer_update_schema(), } ), synchronous=True, @@ -272,7 +282,12 @@ async def lvgl_update_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config, CONF_LVGL_ID) w = widgets[0] async with LambdaContext(LVGL_COMP_ARG, where=action_id) as context: + # Apply the top-level properties (styles and layout) to the active screen... + await set_obj_properties(get_screen_active(w.var), config) + # ...the deprecated flat `disp_*` background properties... await lvgl_update(w.var, config) + # ...and the `top_layer`/`bottom_layer` keys (styling and layout updates). + await layers_to_code(w.var, config) var = cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) await cg.register_parented(var, w.var) return var diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index d9be881a7f..4f734fe20c 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -214,11 +214,14 @@ class LValidator: has `process()` to convert a value during code generation """ - def __init__(self, validator, rtype: MockObj, retmapper=None, requires=None): + def __init__( + self, validator, rtype: MockObj, retmapper=None, requires=None, animatable=False + ): self.validator = validator self.rtype = rtype self.retmapper = retmapper self.requires = requires + self.animatable = animatable def __call__(self, value): if self.requires: @@ -228,7 +231,10 @@ class LValidator: return self.validator(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: if value is None: return None @@ -236,11 +242,15 @@ class LValidator: # Local import to avoid circular import from .lvcode import get_lambda_context_args - args = args or get_lambda_context_args() + # `args is None` means "inherit the enclosing lambda context"; an explicit + # empty list means "no parameters" and must be preserved as-is. + if args is None: + args = get_lambda_context_args() - return call_lambda( - await cg.process_lambda(value, args, return_type=self.rtype) - ) + lamb = await cg.process_lambda(value, args, return_type=self.rtype) + if raw_lambda: + return lamb + return call_lambda(lamb) if self.retmapper is not None: return self.retmapper(value) if isinstance(value, ID): @@ -729,6 +739,7 @@ CONF_GRID_ROWS = "grid_rows" CONF_HEADER_BUTTONS = "header_buttons" CONF_HEADER_MODE = "header_mode" CONF_HOME = "home" +CONF_IMAGE = "image" CONF_INDICATORS = "indicators" CONF_INITIAL_FOCUS = "initial_focus" CONF_SELECTED_DIGIT = "selected_digit" @@ -742,15 +753,19 @@ CONF_LONG_PRESS_REPEAT_TIME = "long_press_repeat_time" CONF_LVGL_ID = "lvgl_id" CONF_LONG_MODE = "long_mode" CONF_MAJOR_TICKS_STYLE = "major_ticks_style" +CONF_MAPPING = "mapping" CONF_MSGBOXES = "msgboxes" CONF_OBJ = "obj" CONF_ONE_CHECKED = "one_checked" CONF_ONE_LINE = "one_line" CONF_ON_DRAW_START = "on_draw_start" CONF_ON_DRAW_END = "on_draw_end" +CONF_ON_LANDSCAPE = "on_landscape" CONF_ON_PAUSE = "on_pause" +CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" +CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" @@ -758,12 +773,14 @@ CONF_PAD_COLUMN = "pad_column" CONF_PAGE = "page" CONF_PAGE_WRAP = "page_wrap" CONF_PASSWORD_MODE = "password_mode" +CONF_PAUSED = "paused" CONF_PIVOT_X = "pivot_x" CONF_PIVOT_Y = "pivot_y" CONF_PLACEHOLDER_TEXT = "placeholder_text" CONF_POINTS = "points" CONF_PREVIOUS = "previous" CONF_RADIUS = "radius" +CONF_REFRESH_INTERVAL = "refresh_interval" CONF_REPEAT_COUNT = "repeat_count" CONF_RECOLOR = "recolor" CONF_RESUME_ON_INPUT = "resume_on_input" diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index 32304276d3..fd1f242d86 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -34,6 +34,7 @@ from .defines import ( TYPE_GRID, TYPE_NONE, LvConstant, + add_lv_use, ) from .lv_validation import padding, size @@ -401,6 +402,61 @@ LAYOUT_CLASSES = ( LAYOUT_CHOICES = [x.get_type() for x in LAYOUT_CLASSES] +# Layout properties that may be changed at runtime via an update action. These +# are limited to simple style properties (set via ``lv_obj_set_style_...``). +# Structural properties are deliberately excluded: +# - the layout ``type``, which determines which options are available to child +# widgets, and +# - the grid ``grid_rows``/``grid_columns`` descriptors, which define the cells +# that child widgets are placed into. +# Both are fixed at widget creation. +_GRID_LAYOUT_KEYS = ( + CONF_GRID_COLUMN_ALIGN, + CONF_GRID_ROW_ALIGN, +) +_FLEX_LAYOUT_KEYS = ( + CONF_FLEX_FLOW, + CONF_FLEX_ALIGN_MAIN, + CONF_FLEX_ALIGN_CROSS, + CONF_FLEX_ALIGN_TRACK, +) + +LAYOUT_UPDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_FLEX_FLOW): FLEX_FLOWS.one_of, + cv.Optional(CONF_FLEX_ALIGN_MAIN): flex_alignments, + cv.Optional(CONF_FLEX_ALIGN_CROSS): LV_FLEX_CROSS_ALIGNMENTS.one_of, + cv.Optional(CONF_FLEX_ALIGN_TRACK): flex_alignments, + cv.Optional(CONF_GRID_COLUMN_ALIGN): grid_alignments, + cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, + cv.Optional(CONF_PAD_ROW): padding, + cv.Optional(CONF_PAD_COLUMN): padding, + } +) + + +def layout_validator(value): + """ + Validate a ``layout:`` value for an update action. Only the layout options + may be changed (not the layout ``type``, which is fixed at widget creation). + :param value: The value of the ``layout:`` key + :return: The validated layout options dict + """ + result = LAYOUT_UPDATE_SCHEMA(value) + if not result: + raise cv.Invalid( + "A layout update must specify at least one layout option", [CONF_LAYOUT] + ) + # Register the relevant layout feature so its LV_USE_* define is emitted even + # when the option is set solely via an update action (whose code generation + # may run after LVGL has finished collecting its used features). + if any(key in result for key in _GRID_LAYOUT_KEYS): + add_lv_use(TYPE_GRID) + if any(key in result for key in _FLEX_LAYOUT_KEYS): + add_lv_use(TYPE_FLEX) + return result + + def append_layout_schema(schema, config: dict): """ Get the child layout schema for a given widget based on its layout type. diff --git a/esphome/components/lvgl/light/lvgl_light.h b/esphome/components/lvgl/light/lvgl_light.h index bf019964c7..37dc4135ce 100644 --- a/esphome/components/lvgl/light/lvgl_light.h +++ b/esphome/components/lvgl/light/lvgl_light.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVLight : public light::LightOutput { +class LVLight final : public light::LightOutput { public: light::LightTraits get_traits() override { auto traits = light::LightTraits(); diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 27cbfff694..42352b9602 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -22,9 +22,12 @@ from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType +from ..mapping import INDEX_TYPES, get_mapping_metadata from . import types as ty from .defines import ( CONF_END_VALUE, + CONF_IMAGE, + CONF_MAPPING, CONF_START_VALUE, CONF_TIME_FORMAT, LV_FONTS, @@ -60,6 +63,7 @@ opacity = LValidator( opacity_validator, lv_opa_t, retmapper=lambda opa: StaticCastExpression(cg.uint8, opa * 255.0), + animatable=True, ) COLOR_NAMES = { @@ -223,35 +227,33 @@ def color(value): ) -def color_retmapper(value): - if isinstance(value, cv.Lambda): - return cv.returning_lambda(value) +def get_component_colors(value): if isinstance(value, str) and value in COLOR_NAMES: value = COLOR_NAMES[value] if isinstance(value, int): - return literal( - f"lv_color_make({(value >> 16) & 0xFF}, {(value >> 8) & 0xFF}, {value & 0xFF})" - ) + return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: r, g, b, _ = from_rgbw(cval) - return literal(f"lv_color_make({r}, {g}, {b})") + return r, g, b raise AssertionError(f"Unhandled lv_color value: {value!r}") -def option_string(value): - value = cv.string(value).strip() - if value.find("\n") != -1: - raise cv.Invalid("Options strings must not contain newlines") - return value +def color_retmapper(value): + if isinstance(value, cv.Lambda): + return cv.returning_lambda(value) + r, g, b = get_component_colors(value) + return literal(f"lv_color_make({r}, {g}, {b})") class LvColor(LValidator): def __init__(self): - super().__init__(color, ty.lv_color_t, retmapper=color_retmapper) + super().__init__( + color, ty.lv_color_t, retmapper=color_retmapper, animatable=True + ) def __getattr__(self, item): if item in COLOR_NAMES: @@ -262,6 +264,13 @@ class LvColor(LValidator): lv_color = LvColor() +def option_string(value): + value = cv.string(value).strip() + if value.find("\n") != -1: + raise cv.Invalid("Options strings must not contain newlines") + return value + + def pixels_or_percent_validator(value): """A length in one axis - either a number (pixels) or a percentage""" if value == SCHEMA_EXTRACT: @@ -277,6 +286,7 @@ pixels_or_percent = LValidator( pixels_or_percent_validator, lv_coord_t, retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"), + animatable=True, ) @@ -315,10 +325,23 @@ def angle(value): # Validator for angles in LVGL expressed in 1/10 degree units. -lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10)) +lv_angle = LValidator(angle, uint32, retmapper=lambda x: int(x * 10), animatable=True) # Validator for angles in LVGL expressed in whole degrees -lv_angle_degrees = LValidator(angle, uint32, retmapper=int) +lv_angle_degrees = LValidator(angle, uint32, retmapper=int, animatable=True) + + +def rotation_degrees(value): + """Validate a display rotation, returning the angle in whole degrees. + + Accepts the four supported rotations, optionally suffixed with "°". + """ + value = cv.string(value).removesuffix("°") + return cv.one_of(0, 90, 180, 270, int=True)(value) + + +# Validator for a display rotation expressed in whole degrees (templatable) +lv_rotation = LValidator(rotation_degrees, cg.int_) @schema_extractor("one_of") @@ -368,21 +391,57 @@ def stop_value(value): return cv.int_range(0, 255)(value) -def image_validator(value): - value = cv.requires_component("image")(value) +def _image_validator(value): + if isinstance(value, dict) and CONF_MAPPING in value: + from .schemas import MAPPING_IMAGE_SCHEMA + + return MAPPING_IMAGE_SCHEMA(value) value = cv.use_id(Image_)(value) get_lv_images_used().add(value) add_lv_use("label") return value -lv_image = LValidator( - image_validator, - image.Image_.operator("ptr"), - requires="image", -) +class ImageValidator(LValidator): + def __init__(self): + super().__init__( + validator=_image_validator, + rtype=image.Image_.operator("ptr"), + requires=CONF_IMAGE, + ) + + async def process( + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, + ) -> Expression: + # Local import to avoid circular import at module level + from .lvcode import get_lambda_context_args + + args = args or get_lambda_context_args() + if isinstance(value, dict) and CONF_MAPPING in value: + mapping_id = value[CONF_MAPPING] + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index) + + return await super().process(value, args, raw_lambda) + + +lv_image = ImageValidator() + lv_image_list = LValidator( - cv.ensure_list(image_validator), + cv.ensure_list(_image_validator), cg.std_vector.template(image.Image_.operator("ptr")), requires="image", ) @@ -410,7 +469,10 @@ class TextValidator(LValidator): return super().__call__(value) async def process( - self, value: Any, args: list[tuple[SafeExpType, str]] | None = None + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, ) -> Expression: # Local import to avoid circular import at module level from .lvcode import get_lambda_context_args @@ -430,6 +492,24 @@ class TextValidator(LValidator): f"(std::isfinite({arg_expr}) ? {sprintf_str} : {nanval})" ) return literal(sprintf_str) + if mapping_id := value.get(CONF_MAPPING): + mapping_var = await cg.get_variable(mapping_id) + metadata = get_mapping_metadata(mapping_id.id) + if metadata.to_ != INDEX_TYPES["string"]: + raise ValueError( + f"Mapping {mapping_id} does not map to strings, cannot use in text" + ) + index = value[CONF_VALUE] + if isinstance(index, Lambda): + index = call_lambda( + await cg.process_lambda( + index, args, return_type=metadata.from_.data_type + ) + ) + else: + index = await metadata.from_.convert_value(index) + return mapping_var.get(index).c_str() + if time_format := value.get(CONF_TIME_FORMAT): source = value[CONF_TIME] if isinstance(source, Lambda): @@ -455,13 +535,18 @@ class TextValidator(LValidator): return value # Either a std::string or a lambda call returning that. We need const char* return MockObj(f"({value}).c_str()") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_text = TextValidator() lv_float = LValidator(cv.float_, cg.float_) -lv_int = LValidator(cv.int_, cg.int_) -lv_positive_int = LValidator(cv.positive_int, cg.int_) +lv_positive_float = LValidator(cv.positive_float, cg.float_) +lv_zero_to_one_float = LValidator(cv.zero_to_one_float, cg.float_) +lv_int = LValidator(cv.int_, cg.int_, animatable=True) +lv_positive_int = LValidator(cv.positive_int, cg.int_, animatable=True) +lv_brightness = LValidator( + cv.percentage, cg.float_, retmapper=lambda x: int(x * 255), animatable=True +) def _percentage_validator(value): @@ -508,12 +593,17 @@ class LvFont(LValidator): # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* super().__init__(validator, Font.operator("ptr")) - async def process(self, value, args=()): + async def process( + self, + value: Any, + args: list[tuple[SafeExpType, str]] | None = None, + raw_lambda: bool = False, + ): if is_lv_font(value): return literal(f"&lv_font_{value}") if isinstance(value, str): return literal(f"{value}") - return await super().process(value, args) + return await super().process(value, args, raw_lambda) lv_font = LvFont() diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 12bf6d9f37..b66a904437 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -91,11 +91,24 @@ void LvglComponent::set_rotation(display::DisplayRotation rotation) { this->rotation_ = rotation; if (this->is_ready()) { this->set_resolution_(); + this->update_orientation_(); lv_obj_update_layout(this->get_screen_active()); lv_obj_invalidate(this->get_screen_active()); } } +void LvglComponent::set_rotation(int angle) { + // Normalize to [0, 360). The DisplayRotation enum values are the angles in degrees. + angle %= 360; + if (angle < 0) + angle += 360; + if (angle % 90 != 0) { + ESP_LOGW(TAG, "Invalid rotation angle %d; must be a multiple of 90 degrees.", angle); + return; + } + this->set_rotation(static_cast(angle)); +} + void LvglComponent::rotate_coordinates(int32_t &x, int32_t &y) const { switch (this->rotation_) { default: @@ -401,7 +414,10 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_data *ptr) { } void LvglComponent::flush_cb_(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p) { - if (!this->is_paused()) { + // no guard here for display busy, since LVGL will not call flush_cb until the refresh timer fires, + // and while the display is busy this is reset to 5 minutes. If that expires and the display is still + // busy there are bigger problems. + if (!this->paused_) { auto now = millis(); this->draw_buffer_(area, reinterpret_cast(color_p)); ESP_LOGV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", (int) area->x1, (int) area->y1, @@ -620,20 +636,20 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { void LvglComponent::draw_end_() { if (this->draw_end_callback_ != nullptr) this->draw_end_callback_->trigger(); + // Only reachable once the display is idle again: while busy, the display's refr_timer_ is + // paused (see loop()), so LVGL never renders/flushes and this event never fires. if (this->update_when_display_idle_) { for (auto *disp : this->displays_) disp->update(); } } -bool LvglComponent::is_paused() const { - if (this->paused_) - return true; - if (this->update_when_display_idle_) { - for (auto *disp : this->displays_) { - if (!disp->is_idle()) - return true; - } +bool LvglComponent::displays_busy_() const { + if (!this->update_when_display_idle_) + return false; + for (auto *disp : this->displays_) { + if (!disp->is_idle()) + return true; } return false; } @@ -716,6 +732,18 @@ void LvglComponent::set_resolution_() const { } lv_display_set_resolution(this->disp_, width, height); } + +void LvglComponent::update_orientation_() { + // A square display is treated as landscape. + auto orientation = this->get_width() >= this->get_height() ? Orientation::LANDSCAPE : Orientation::PORTRAIT; + if (orientation == this->orientation_) + return; + this->orientation_ = orientation; + auto *trigger = orientation == Orientation::LANDSCAPE ? this->landscape_callback_ : this->portrait_callback_; + if (trigger != nullptr) + trigger->trigger(); +} + void LvglComponent::setup() { auto *display = this->displays_[0]; auto rounding = this->draw_rounding; @@ -754,7 +782,7 @@ void LvglComponent::setup() { lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); lv_display_set_buffers(this->disp_, this->draw_buf_, nullptr, buf_bytes, this->full_refresh_ ? LV_DISPLAY_RENDER_MODE_FULL : LV_DISPLAY_RENDER_MODE_PARTIAL); - if (this->rotation_type_ == RotationType::ROTATION_SOFTWARE) { + if (this->rotation_type_ == ROTATION_SOFTWARE) { this->rotate_buf_ = static_cast(lv_alloc_draw_buf(buf_bytes, false)); // NOLINT if (this->rotate_buf_ == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); @@ -777,6 +805,8 @@ void LvglComponent::setup() { if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { lv_display_add_event_cb(this->disp_, render_end_cb, LV_EVENT_REFR_READY, this); } + this->refr_timer_ = lv_display_get_refr_timer(this->disp_); + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); #if LV_USE_LOG lv_log_register_print_cb([](lv_log_level_t level, const char *buf) { auto next = strchr(buf, ')'); @@ -791,6 +821,7 @@ void LvglComponent::setup() { #endif this->show_page(0, LV_SCREEN_LOAD_ANIM_NONE, 0); lv_display_trigger_activity(this->disp_); + this->update_orientation_(); } void LvglComponent::update() { @@ -802,21 +833,32 @@ void LvglComponent::update() { } void LvglComponent::loop() { - if (this->is_paused()) { - if (this->paused_ && this->show_snow_) + if (this->paused_) { + if (this->show_snow_) this->write_random_(); - } else { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - auto now = millis(); - lv_timer_handler(); - auto elapsed = millis() - now; - if (elapsed > 15) { - ESP_LOGV(TAG, "lv_timer_handler took %dms", (int) (millis() - now)); - } -#else - lv_timer_handler(); -#endif + return; } + // Pause/resume the display's own refresh timer to track its busy state. While paused, LVGL + // still keeps track of invalidated areas but won't render or flush them, so nothing needs to + // be discarded or replayed: once resumed, the accumulated areas are simply drawn as normal. + // Input events and other timers keep being processed below regardless of this state. + if (this->update_when_display_idle_) { + bool busy = this->displays_busy_(); + if (busy && !this->refr_timer_paused_) { + this->refr_timer_paused_ = true; + // calling lv_timer_pause() here would be ineffective; LVGL pauses and resumes the timer based on its own internal + // state, which is not aware of the display's busy state. Instead, we extend the timer period to avoid it firing + // while the display is busy. + lv_timer_set_period(this->refr_timer_, 5 * 60 * 1000); + } else if (!busy && this->refr_timer_paused_) { + this->refr_timer_paused_ = false; + lv_timer_set_period(this->refr_timer_, this->refr_timer_period_); + // Don't wait for the timer's next natural period: refresh right away now that the + // display is idle again. + lv_timer_ready(this->refr_timer_); + } + } + lv_timer_handler(); } #ifdef USE_LVGL_ANIMIMG @@ -929,7 +971,7 @@ void lv_mem_init() {} void lv_mem_deinit() {} -#if defined(USE_HOST) || defined(USE_RP2040) || defined(USE_ESP8266) +#if defined(USE_HOST) || defined(USE_RP2) || defined(USE_ESP8266) void *lv_malloc_core(size_t size) { auto *ptr = malloc(size); // NOLINT if (ptr == nullptr) { diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 3f7f1dce14..9221ab9542 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -155,7 +155,7 @@ class LvPageType : public Parented { using event_callback_t = void(lv_event_t *); -class LvLambdaComponent : public Component { +class LvLambdaComponent final : public Component { public: LvLambdaComponent(void (*callback)()) : callback_(callback) {} @@ -167,7 +167,7 @@ class LvLambdaComponent : public Component { void (*callback_)(); }; -template class ObjUpdateAction : public Action { +template class ObjUpdateAction final : public Action { public: explicit ObjUpdateAction(std::function &&lamb) : lamb_(std::move(lamb)) {} @@ -185,7 +185,13 @@ enum RotationType : uint8_t { ROTATION_HARDWARE, }; -class LvglComponent : public PollingComponent { +enum class Orientation : uint8_t { + UNKNOWN, + LANDSCAPE, + PORTRAIT, +}; + +class LvglComponent final : public PollingComponent { constexpr static const char *const TAG = "lvgl"; public: @@ -214,9 +220,14 @@ class LvglComponent : public PollingComponent { // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. void set_paused(bool paused, bool show_snow); + void set_refresh_interval(uint32_t period) { + this->refr_timer_period_ = period; + if (this->refr_timer_ != nullptr) + lv_timer_set_period(this->refr_timer_, period); + } - // Returns true if the display is explicitly paused, or a blocking display update is in progress. - bool is_paused() const; + // Returns true if the display has been explicitly paused via set_paused(). + bool is_paused() const { return this->paused_; } // If the display is paused and we have resume_on_input_ set to true, resume the display. void maybe_wakeup() { if (this->paused_ && this->resume_on_input_) { @@ -286,7 +297,11 @@ class LvglComponent : public PollingComponent { void set_resume_trigger(Trigger<> *trigger) { this->resume_callback_ = trigger; } void set_draw_start_trigger(Trigger<> *trigger) { this->draw_start_callback_ = trigger; } void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } + void set_landscape_trigger(Trigger<> *trigger) { this->landscape_callback_ = trigger; } + void set_portrait_trigger(Trigger<> *trigger) { this->portrait_callback_ = trigger; } void set_rotation(display::DisplayRotation rotation); + /// Set the rotation from an angle in degrees. Must be a multiple of 90. + void set_rotation(int angle); display::DisplayRotation get_rotation() const { return this->rotation_; } void rotate_coordinates(int32_t &x, int32_t &y) const; @@ -295,10 +310,16 @@ class LvglComponent : public PollingComponent { protected: void set_resolution_() const; + // Determine the current orientation from the effective resolution and fire the + // landscape/portrait trigger if it has changed since the last check. + void update_orientation_(); void draw_end_(); // Not checking for non-null callback since the // LVGL callback that calls it is not set in that case void draw_start_() const { this->draw_start_callback_->trigger(); } + // Returns true if update_when_display_idle is enabled and at least one underlying display + // component is currently busy (e.g. mid-refresh). + bool displays_busy_() const; void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_data *ptr); @@ -316,6 +337,14 @@ class LvglComponent : public PollingComponent { uint8_t *draw_buf_{}; lv_display_t *disp_{}; + // The display's own periodic refresh timer, effectively paused while the display is busy (see + // displays_busy_()) so LVGL neither renders nor flushes to it, without losing track of + // invalidated areas. Other timers (indev reading, animations, ...) keep running as normal. + lv_timer_t *refr_timer_{}; + // Tracks whether refr_timer_ is currently paused, so loop() can detect the busy -> idle edge + // and kick off an immediate refresh instead of waiting for the timer's next natural period. + bool refr_timer_paused_{}; + uint32_t refr_timer_period_{16}; uint16_t width_{}; uint16_t height_{}; bool paused_{}; @@ -331,6 +360,9 @@ class LvglComponent : public PollingComponent { Trigger<> *resume_callback_{}; Trigger<> *draw_start_callback_{}; Trigger<> *draw_end_callback_{}; + Trigger<> *landscape_callback_{}; + Trigger<> *portrait_callback_{}; + Orientation orientation_{Orientation::UNKNOWN}; void *rotate_buf_{}; display::DisplayRotation rotation_{display::DISPLAY_ROTATION_0_DEGREES}; RotationType rotation_type_; @@ -339,7 +371,7 @@ class LvglComponent : public PollingComponent { #endif }; -class IdleTrigger : public Trigger<> { +class IdleTrigger final : public Trigger<> { public: explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); @@ -348,7 +380,7 @@ class IdleTrigger : public Trigger<> { bool is_idle_{}; }; -template class LvglAction : public Action, public Parented { +template class LvglAction final : public Action, public Parented { public: explicit LvglAction(std::function &&lamb) : action_(std::move(lamb)) {} @@ -357,7 +389,7 @@ template class LvglAction : public Action, public Parente std::function action_{}; }; -template class LvglCondition : public Condition, public Parented { +template class LvglCondition final : public Condition, public Parented { public: LvglCondition(std::function &&condition_lambda) : condition_lambda_(std::move(condition_lambda)) {} bool check(const Ts &...x) override { return this->condition_lambda_(this->parent_); } @@ -367,7 +399,7 @@ template class LvglCondition : public Condition { +class LVTouchListener final : public touchscreen::TouchListener, public Parented { public: LVTouchListener(uint16_t long_press_time, uint16_t long_press_repeat_time, LvglComponent *parent); void update(const touchscreen::TouchPoints_t &tpoints) override; @@ -403,7 +435,7 @@ class IndicatorLine : public LvCompound { #endif #ifdef USE_LVGL_KEY_LISTENER -class LVEncoderListener : public Parented { +class LVEncoderListener final : public Parented { public: LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time); diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 3fda9427c5..eb2f70b4da 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -8,7 +8,7 @@ namespace esphome::lvgl { -class LVGLNumber : public number::Number, public Component { +class LVGLNumber final : public number::Number, public Component { public: LVGLNumber(std::function control_lambda, std::function value_lambda, bool restore) : control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {} diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index d7df628907..dd4f71a346 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -3,6 +3,7 @@ from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation +from esphome.components.mapping import mapping_class from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path from esphome.const import ( @@ -17,6 +18,7 @@ from esphome.const import ( CONF_TEXT, CONF_TIME, CONF_TRIGGER_ID, + CONF_VALUE, CONF_X, CONF_Y, ) @@ -31,6 +33,7 @@ from esphome.schema_extractors import ( from . import defines as df, lv_validation as lvalid from .defines import ( CONF_EXT_CLICK_AREA, + CONF_MAPPING, CONF_SCROLL_DIR, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, @@ -52,6 +55,7 @@ from .layout import ( GRID_CELL_SCHEMA, append_layout_schema, grid_alignments, + layout_validator, ) from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr @@ -89,6 +93,20 @@ PRINTF_TEXT_SCHEMA = cv.All( validate_printf, ) +MAPPING_TEXT_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + +MAPPING_IMAGE_SCHEMA = cv.Schema( + { + cv.Required(CONF_MAPPING): cv.use_id(mapping_class), + cv.Required(CONF_VALUE): cv.templatable(cv.string), + } +) + def _validate_text(value): """ @@ -100,6 +118,8 @@ def _validate_text(value): if isinstance(value, dict): if CONF_TIME_FORMAT in value: return TIME_TEXT_SCHEMA(value) + if CONF_MAPPING in value: + return MAPPING_TEXT_SCHEMA(value) return PRINTF_TEXT_SCHEMA(value) return cv.templatable(cv.string)(value) @@ -504,6 +524,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): ) ), cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(df.CONF_LAYOUT): layout_validator, } ) diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index ffbe29d701..e36357328c 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -10,7 +10,7 @@ namespace esphome::lvgl { -class LVGLSelect : public select::Select, public Component { +class LVGLSelect final : public select::Select, public Component { public: LVGLSelect(LvSelectable *widget, lv_anim_enable_t anim, bool restore) : widget_(widget), anim_(anim), restore_(restore) {} diff --git a/esphome/components/lvgl/switch/lvgl_switch.h b/esphome/components/lvgl/switch/lvgl_switch.h index 8f5502a7d5..ea15767ba8 100644 --- a/esphome/components/lvgl/switch/lvgl_switch.h +++ b/esphome/components/lvgl/switch/lvgl_switch.h @@ -9,7 +9,7 @@ namespace esphome::lvgl { -class LVGLSwitch : public switch_::Switch, public Component { +class LVGLSwitch final : public switch_::Switch, public Component { public: LVGLSwitch(std::function state_lambda) : state_lambda_(std::move(state_lambda)) {} diff --git a/esphome/components/lvgl/text/lvgl_text.h b/esphome/components/lvgl/text/lvgl_text.h index fead48d6fe..8d83f323ab 100644 --- a/esphome/components/lvgl/text/lvgl_text.h +++ b/esphome/components/lvgl/text/lvgl_text.h @@ -6,7 +6,7 @@ namespace esphome::lvgl { -class LVGLText : public text::Text { +class LVGLText final : public text::Text { public: void set_control_lambda(const std::function &control_lambda) { this->control_lambda_ = control_lambda; diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 509d5cc782..61efe385e6 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -67,6 +67,7 @@ lv_obj_t = LvType("lv_obj_t") lv_page_t = LvType("LvPageType", parents=(LvCompound,)) lv_image_t = LvType("lv_image_t") lv_gradient_t = LvType("lv_grad_dsc_t") +LvAnimation = lvgl_ns.class_("LvAnimation", cg.Component) lv_event_t = LvType("lv_event_t") RotationType = lvgl_ns.enum("RotationType") lv_point_t = cg.global_ns.struct("lv_point_t") diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 4d62c3de05..968db46adc 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -36,11 +36,10 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + LOGGER, OBJ_FLAGS, PARTS, STATES, - TYPE_FLEX, - TYPE_GRID, LValidator, add_lv_use, call_lambda, @@ -541,44 +540,76 @@ def _size_to_str(value): return str(value) +def _grid_descriptor_array(name: str, specs) -> MockObj: + """Generate a file-scope ``static const`` grid row/column descriptor array + and return a reference to it.""" + values = ",".join(_size_to_str(x) for x in specs) + initializer = "{" + values + ", LV_GRID_TEMPLATE_LAST}" + arr_id = ID(name, is_declaration=True, type=lv_coord_t) + return cg.static_const_array(arr_id, cg.RawExpression(initializer)) + + +def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: + """Apply the layout options present in ``layout`` to ``w``. + + Only options actually present are applied, so this works both for widget + creation (where every option is supplied) and for update actions (where the + layout ``type`` and grid structure are fixed and only the style options are + changed). ``base_name`` names the generated grid descriptor arrays and is + only required at creation, when ``grid_rows``/``grid_columns`` are present. + """ + if (pad_row := layout.get(CONF_PAD_ROW)) is not None: + w.set_style(CONF_PAD_ROW, pad_row) + if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: + w.set_style(CONF_PAD_COLUMN, pad_column) + if (rows := layout.get(CONF_GRID_ROWS)) is not None: + w.set_style( + "grid_row_dsc_array", _grid_descriptor_array(f"{base_name}_row_dsc", rows) + ) + if (columns := layout.get(CONF_GRID_COLUMNS)) is not None: + w.set_style( + "grid_column_dsc_array", + _grid_descriptor_array(f"{base_name}_column_dsc", columns), + ) + if (align := layout.get(CONF_GRID_COLUMN_ALIGN)) is not None: + w.set_style(CONF_GRID_COLUMN_ALIGN, literal(align)) + if (align := layout.get(CONF_GRID_ROW_ALIGN)) is not None: + w.set_style(CONF_GRID_ROW_ALIGN, literal(align)) + if (flow := layout.get(CONF_FLEX_FLOW)) is not None: + lv_obj.set_flex_flow(w.obj, literal(flow)) + if (main := layout.get(CONF_FLEX_ALIGN_MAIN)) is not None: + w.set_style("flex_main_place", literal(main)) + if (cross := layout.get(CONF_FLEX_ALIGN_CROSS)) is not None: + # Stretch is implemented at creation time by sizing the children; at + # runtime we can only fall back to centering. + if cross == "LV_FLEX_ALIGN_STRETCH": + LOGGER.warning( + "Flex cross alignment 'stretch' is not supported at runtime; using 'center' instead" + ) + cross = "LV_FLEX_ALIGN_CENTER" + w.set_style("flex_cross_place", literal(cross)) + if (track := layout.get(CONF_FLEX_ALIGN_TRACK)) is not None: + w.set_style("flex_track_place", literal(track)) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" from ..schemas import ALL_STYLES, OBJ_PROPERTIES, remap_property if layout := config.get(CONF_LAYOUT): - layout_type: str = layout[CONF_TYPE] - add_lv_use(layout_type) - lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) - if (pad_row := layout.get(CONF_PAD_ROW)) is not None: - w.set_style(CONF_PAD_ROW, pad_row) - if (pad_column := layout.get(CONF_PAD_COLUMN)) is not None: - w.set_style(CONF_PAD_COLUMN, pad_column) - if layout_type == TYPE_GRID: - wid = config[CONF_ID] - rows = [_size_to_str(x) for x in layout[CONF_GRID_ROWS]] - rows = "{" + ",".join(rows) + ", LV_GRID_TEMPLATE_LAST}" - row_id = ID(f"{wid}_row_dsc", is_declaration=True, type=lv_coord_t) - row_array = cg.static_const_array(row_id, cg.RawExpression(rows)) - w.set_style("grid_row_dsc_array", row_array) - columns = [_size_to_str(x) for x in layout[CONF_GRID_COLUMNS]] - columns = "{" + ",".join(columns) + ", LV_GRID_TEMPLATE_LAST}" - column_id = ID(f"{wid}_column_dsc", is_declaration=True, type=lv_coord_t) - column_array = cg.static_const_array(column_id, cg.RawExpression(columns)) - w.set_style("grid_column_dsc_array", column_array) - w.set_style( - CONF_GRID_COLUMN_ALIGN, literal(layout.get(CONF_GRID_COLUMN_ALIGN)) - ) - w.set_style(CONF_GRID_ROW_ALIGN, literal(layout.get(CONF_GRID_ROW_ALIGN))) - if layout_type == TYPE_FLEX: - lv_obj.set_flex_flow(w.obj, literal(layout[CONF_FLEX_FLOW])) - main = literal(layout[CONF_FLEX_ALIGN_MAIN]) - cross = layout[CONF_FLEX_ALIGN_CROSS] - if cross == "LV_FLEX_ALIGN_STRETCH": - cross = "LV_FLEX_ALIGN_CENTER" - cross = literal(cross) - track = literal(layout[CONF_FLEX_ALIGN_TRACK]) - lv_obj.set_flex_align(w.obj, main, cross, track) + # The layout `type` (and the grid row/column structure) is only present + # when a widget is created; update actions only change the layout style + # options, leaving the type and grid structure unchanged. + layout_type = layout.get(CONF_TYPE) + if layout_type is not None: + add_lv_use(layout_type) + lv_obj.set_layout(w.obj, literal(f"LV_LAYOUT_{layout_type.upper()}")) + # The widget's own id gives the grid descriptor arrays stable names. + base_name = str(config[CONF_ID]) + else: + base_name = None + _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): part = "LV_PART_" + part.upper() diff --git a/esphome/components/lvgl/widgets/img.py b/esphome/components/lvgl/widgets/img.py index 8a046fea33..da81ab7737 100644 --- a/esphome/components/lvgl/widgets/img.py +++ b/esphome/components/lvgl/widgets/img.py @@ -1,3 +1,5 @@ +from esphome.components.image import INSTANCE_TYPE as IMAGE_TYPE +from esphome.components.mapping import get_mapping_metadata import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -9,7 +11,9 @@ from esphome.const import ( from ..defines import ( CONF_ANTIALIAS, + CONF_IMAGE, CONF_MAIN, + CONF_MAPPING, CONF_PIVOT_X, CONF_PIVOT_Y, CONF_SCALE, @@ -21,8 +25,6 @@ from ..types import lv_image_t from . import Widget, WidgetType from .label import CONF_LABEL -CONF_IMAGE = "image" - BASE_IMG_SCHEMA = cv.Schema( { cv.Optional(CONF_PIVOT_X): size, @@ -69,5 +71,16 @@ class ImgType(WidgetType): for prop, validator in BASE_IMG_SCHEMA.schema.items(): await w.set_property(prop, config, processor=validator) + def final_validate(self, widget, update_config, widget_config, path): + src = update_config.get(CONF_SRC) + if isinstance(src, dict) and CONF_MAPPING in src: + mapping_id = src[CONF_MAPPING] + metadata = get_mapping_metadata(mapping_id.id) + if str(metadata.to_.data_type) != str(IMAGE_TYPE): + raise cv.Invalid( + f"Mapping '{mapping_id}' does not map to an image type, but '{metadata.to_.data_type}'", + path=path + [CONF_SRC, CONF_MAPPING], + ) + img_spec = ImgType() diff --git a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h index 14400bcea1..a49f6dbb54 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h +++ b/esphome/components/m5stack_8angle/binary_sensor/m5stack_8angle_binary_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleSwitchBinarySensor : public binary_sensor::BinarySensor, - public PollingComponent, - public Parented { +class M5Stack8AngleSwitchBinarySensor final : public binary_sensor::BinarySensor, + public PollingComponent, + public Parented { public: void update() override; }; diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h index 0a5a50f2a8..ee204c239b 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h @@ -10,7 +10,7 @@ namespace esphome::m5stack_8angle { static const uint8_t M5STACK_8ANGLE_NUM_LEDS = 9; static const uint8_t M5STACK_8ANGLE_BYTES_PER_LED = 4; -class M5Stack8AngleLightOutput : public light::AddressableLight, public Parented { +class M5Stack8AngleLightOutput final : public light::AddressableLight, public Parented { public: void setup() override; diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.h b/esphome/components/m5stack_8angle/m5stack_8angle.h index ab2e232204..058949cad5 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.h +++ b/esphome/components/m5stack_8angle/m5stack_8angle.h @@ -16,7 +16,7 @@ enum AnalogBits : uint8_t { BITS_12 = 12, }; -class M5Stack8AngleComponent : public i2c::I2CDevice, public Component { +class M5Stack8AngleComponent final : public i2c::I2CDevice, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h index 418503d7c8..a270661ad6 100644 --- a/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h +++ b/esphome/components/m5stack_8angle/sensor/m5stack_8angle_sensor.h @@ -7,9 +7,9 @@ namespace esphome::m5stack_8angle { -class M5Stack8AngleKnobSensor : public sensor::Sensor, - public PollingComponent, - public Parented { +class M5Stack8AngleKnobSensor final : public sensor::Sensor, + public PollingComponent, + public Parented { public: void update() override; void set_channel(uint8_t channel) { this->channel_ = channel; }; diff --git a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h index 53ae0b5c03..000a9e5de3 100644 --- a/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h +++ b/esphome/components/matrix_keypad/binary_sensor/matrix_keypad_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::matrix_keypad { -class MatrixKeypadBinarySensor : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { +class MatrixKeypadBinarySensor final : public MatrixKeypadListener, public binary_sensor::BinarySensorInitiallyOff { public: MatrixKeypadBinarySensor(uint8_t key) : has_key_(true), key_(key){}; MatrixKeypadBinarySensor(const char *key) : has_key_(true), key_((uint8_t) key[0]){}; diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 1e263842ea..8c9acc8e0c 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -18,9 +18,9 @@ class MatrixKeypadListener { virtual void key_released(uint8_t key){}; }; -class MatrixKeyTrigger : public Trigger {}; +class MatrixKeyTrigger final : public Trigger {}; -class MatrixKeypad : public key_provider::KeyProvider, public Component { +class MatrixKeypad final : public key_provider::KeyProvider, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/max17043/automation.h b/esphome/components/max17043/automation.h index c98516d259..6b19e5bd5e 100644 --- a/esphome/components/max17043/automation.h +++ b/esphome/components/max17043/automation.h @@ -5,7 +5,7 @@ namespace esphome::max17043 { -template class SleepAction : public Action { +template class SleepAction final : public Action { public: explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {} diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index b59bac7ebf..8776bb5558 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -23,7 +23,7 @@ void MAX17043Component::update() { if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) { this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL")); } else { - float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0; + float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f; this->voltage_sensor_->publish_state(voltage); this->status_clear_warning(); } diff --git a/esphome/components/max17043/max17043.h b/esphome/components/max17043/max17043.h index dd2e35df55..ffe4d916ba 100644 --- a/esphome/components/max17043/max17043.h +++ b/esphome/components/max17043/max17043.h @@ -6,7 +6,7 @@ namespace esphome::max17043 { -class MAX17043Component : public PollingComponent, public i2c::I2CDevice { +class MAX17043Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31855/max31855.h b/esphome/components/max31855/max31855.h index dd7a205268..527d26f99d 100644 --- a/esphome/components/max31855/max31855.h +++ b/esphome/components/max31855/max31855.h @@ -8,10 +8,10 @@ namespace esphome::max31855 { -class MAX31855Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31855Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_sensor(sensor::Sensor *temperature_sensor) { temperature_reference_ = temperature_sensor; } diff --git a/esphome/components/max31856/max31856.h b/esphome/components/max31856/max31856.h index 0a983b72d9..83aa815aa0 100644 --- a/esphome/components/max31856/max31856.h +++ b/esphome/components/max31856/max31856.h @@ -68,10 +68,10 @@ enum MAX31856ConfigFilter { FILTER_50HZ = 1, }; -class MAX31856Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31856Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max31865/max31865.h b/esphome/components/max31865/max31865.h index 3362cd30de..27c107ba0b 100644 --- a/esphome/components/max31865/max31865.h +++ b/esphome/components/max31865/max31865.h @@ -22,10 +22,10 @@ enum MAX31865ConfigFilter { FILTER_50HZ = 1, }; -class MAX31865Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX31865Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void set_reference_resistance(float reference_resistance) { reference_resistance_ = reference_resistance; } void set_nominal_resistance(float nominal_resistance) { rtd_nominal_resistance_ = nominal_resistance; } diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index 12fd0b1ce0..b62aed7a56 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -9,7 +9,7 @@ namespace esphome::max44009 { enum MAX44009Mode { MAX44009_MODE_AUTO, MAX44009_MODE_LOW_POWER, MAX44009_MODE_CONTINUOUS }; /// This class implements support for the MAX44009 Illuminance i2c sensor. -class MAX44009Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: MAX44009Sensor() {} diff --git a/esphome/components/max6675/max6675.h b/esphome/components/max6675/max6675.h index e7b5c4dbde..fc46c8c047 100644 --- a/esphome/components/max6675/max6675.h +++ b/esphome/components/max6675/max6675.h @@ -6,10 +6,10 @@ namespace esphome::max6675 { -class MAX6675Sensor : public sensor::Sensor, - public PollingComponent, - public spi::SPIDevice { +class MAX6675Sensor final : public sensor::Sensor, + public PollingComponent, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/max6956/automation.h b/esphome/components/max6956/automation.h index 547ed5a865..f1db2e3240 100644 --- a/esphome/components/max6956/automation.h +++ b/esphome/components/max6956/automation.h @@ -6,7 +6,7 @@ namespace esphome::max6956 { -template class SetCurrentGlobalAction : public Action { +template class SetCurrentGlobalAction final : public Action { public: SetCurrentGlobalAction(MAX6956 *max6956) : max6956_(max6956) {} @@ -21,7 +21,7 @@ template class SetCurrentGlobalAction : public Action { MAX6956 *max6956_; }; -template class SetCurrentModeAction : public Action { +template class SetCurrentModeAction final : public Action { public: SetCurrentModeAction(MAX6956 *max6956) : max6956_(max6956) {} diff --git a/esphome/components/max6956/max6956.h b/esphome/components/max6956/max6956.h index 83ccfab559..4dbee16528 100644 --- a/esphome/components/max6956/max6956.h +++ b/esphome/components/max6956/max6956.h @@ -35,7 +35,7 @@ enum MAX6956GPIOFlag { FLAG_LED = 0x20 }; enum MAX6956CURRENTMODE { GLOBAL = 0x00, SEGMENT = 0x01 }; -class MAX6956 : public Component, public i2c::I2CDevice { +class MAX6956 final : public Component, public i2c::I2CDevice { public: MAX6956() = default; @@ -69,7 +69,7 @@ class MAX6956 : public Component, public i2c::I2CDevice { int8_t prev_bright_[28] = {0}; }; -class MAX6956GPIOPin : public GPIOPin { +class MAX6956GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/max6956/output/max6956_led_output.h b/esphome/components/max6956/output/max6956_led_output.h index 49e5b9ef84..c40e41371d 100644 --- a/esphome/components/max6956/output/max6956_led_output.h +++ b/esphome/components/max6956/output/max6956_led_output.h @@ -7,7 +7,7 @@ namespace esphome::max6956 { class MAX6956; -class MAX6956LedChannel : public output::FloatOutput, public Component { +class MAX6956LedChannel final : public output::FloatOutput, public Component { public: void set_parent(MAX6956 *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/max7219/max7219.h b/esphome/components/max7219/max7219.h index ef38628f28..3eb4b8e27f 100644 --- a/esphome/components/max7219/max7219.h +++ b/esphome/components/max7219/max7219.h @@ -12,9 +12,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public PollingComponent, - public spi::SPIDevice { +class MAX7219Component final : public PollingComponent, + public spi::SPIDevice { public: explicit MAX7219Component(uint8_t num_chips); diff --git a/esphome/components/max7219digit/automation.h b/esphome/components/max7219digit/automation.h index 485a34075e..f06dfd5087 100644 --- a/esphome/components/max7219digit/automation.h +++ b/esphome/components/max7219digit/automation.h @@ -7,7 +7,7 @@ namespace esphome::max7219digit { -template class DisplayInvertAction : public Action, public Parented { +template class DisplayInvertAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -17,7 +17,7 @@ template class DisplayInvertAction : public Action, publi } }; -template class DisplayVisibilityAction : public Action, public Parented { +template class DisplayVisibilityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -27,7 +27,7 @@ template class DisplayVisibilityAction : public Action, p } }; -template class DisplayReverseAction : public Action, public Parented { +template class DisplayReverseAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, state) @@ -37,7 +37,7 @@ template class DisplayReverseAction : public Action, publ } }; -template class DisplayIntensityAction : public Action, public Parented { +template class DisplayIntensityAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, state) diff --git a/esphome/components/max7219digit/max7219digit.h b/esphome/components/max7219digit/max7219digit.h index bbf43059dd..9e6db20444 100644 --- a/esphome/components/max7219digit/max7219digit.h +++ b/esphome/components/max7219digit/max7219digit.h @@ -24,9 +24,9 @@ class MAX7219Component; using max7219_writer_t = display::DisplayWriter; -class MAX7219Component : public display::DisplayBuffer, - public spi::SPIDevice { +class MAX7219Component final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(max7219_writer_t &&writer) { this->writer_local_ = writer; }; diff --git a/esphome/components/max9611/max9611.h b/esphome/components/max9611/max9611.h index b6fb5d8127..54e6414c79 100644 --- a/esphome/components/max9611/max9611.h +++ b/esphome/components/max9611/max9611.h @@ -33,7 +33,7 @@ enum MAX9611RegisterMap { CONTROL_REGISTER_2_ADRR = 0x0B, }; -class MAX9611Component : public PollingComponent, public i2c::I2CDevice { +class MAX9611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp23008/mcp23008.h b/esphome/components/mcp23008/mcp23008.h index ae2f9e1f3c..38bd9c1ac4 100644 --- a/esphome/components/mcp23008/mcp23008.h +++ b/esphome/components/mcp23008/mcp23008.h @@ -7,7 +7,7 @@ namespace esphome::mcp23008 { -class MCP23008 : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { +class MCP23008 final : public mcp23x08_base::MCP23X08Base, public i2c::I2CDevice { public: MCP23008() = default; diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 4a936a5b02..14c0c9a2fc 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -24,7 +24,7 @@ enum MCP23016GPIORegisters { MCP23016_IOCON1 = 0x0B, }; -class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { +class MCP23016 final : public Component, public i2c::I2CDevice, public gpio_expander::CachedGpioExpander { public: MCP23016() = default; @@ -56,7 +56,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: InternalGPIOPin *interrupt_pin_{nullptr}; }; -class MCP23016GPIOPin : public GPIOPin { +class MCP23016GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 9e3d75575a..173d117457 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -19,6 +19,10 @@ void MCP23017::setup() { this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_); this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_); + // Reset IPOL to 0x00: ESPHome handles 'inverted' in software. + this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00); + this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00); + uint8_t iocon_flags = 0; if (this->open_drain_ints_) { iocon_flags |= IOCON_ODR; diff --git a/esphome/components/mcp23017/mcp23017.h b/esphome/components/mcp23017/mcp23017.h index 86b84f9ad8..c745322bdf 100644 --- a/esphome/components/mcp23017/mcp23017.h +++ b/esphome/components/mcp23017/mcp23017.h @@ -7,7 +7,7 @@ namespace esphome::mcp23017 { -class MCP23017 : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { +class MCP23017 final : public mcp23x17_base::MCP23X17Base, public i2c::I2CDevice { public: MCP23017() = default; diff --git a/esphome/components/mcp23s08/mcp23s08.h b/esphome/components/mcp23s08/mcp23s08.h index 441525469f..270d1467e2 100644 --- a/esphome/components/mcp23s08/mcp23s08.h +++ b/esphome/components/mcp23s08/mcp23s08.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s08 { -class MCP23S08 : public mcp23x08_base::MCP23X08Base, - public spi::SPIDevice { +class MCP23S08 final : public mcp23x08_base::MCP23X08Base, + public spi::SPIDevice { public: MCP23S08() = default; diff --git a/esphome/components/mcp23s17/mcp23s17.h b/esphome/components/mcp23s17/mcp23s17.h index 0cc9321c88..5346b2c8e2 100644 --- a/esphome/components/mcp23s17/mcp23s17.h +++ b/esphome/components/mcp23s17/mcp23s17.h @@ -7,9 +7,9 @@ namespace esphome::mcp23s17 { -class MCP23S17 : public mcp23x17_base::MCP23X17Base, - public spi::SPIDevice { +class MCP23S17 final : public mcp23x17_base::MCP23X17Base, + public spi::SPIDevice { public: MCP23S17() = default; diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 5904a1eef6..1c45b0f4af 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -56,7 +56,7 @@ template class MCP23XXXBase : public Component, public gpio_expander: InternalGPIOPin *interrupt_pin_{nullptr}; }; -template class MCP23XXXGPIOPin : public GPIOPin { +template class MCP23XXXGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index b77d9a2582..960e150800 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -51,9 +51,9 @@ enum STAT : uint8_t { STAT_RX0IF = (1 << 0), STAT_RX1IF = (1 << 1) }; static const uint8_t STAT_RXIF_MASK = STAT_RX0IF | STAT_RX1IF; static const uint8_t EFLG_ERRORMASK = EFLG_RX1OVR | EFLG_RX0OVR | EFLG_TXBO | EFLG_TXEP | EFLG_RXEP; -class MCP2515 : public canbus::Canbus, - public spi::SPIDevice { +class MCP2515 final : public canbus::Canbus, + public spi::SPIDevice { public: MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; diff --git a/esphome/components/mcp3008/mcp3008.h b/esphome/components/mcp3008/mcp3008.h index 1b1b50c793..d45d587ae8 100644 --- a/esphome/components/mcp3008/mcp3008.h +++ b/esphome/components/mcp3008/mcp3008.h @@ -6,10 +6,10 @@ namespace esphome::mcp3008 { -class MCP3008 : public Component, - public spi::SPIDevice { // Running at the slowest max speed supported by the - // mcp3008. 2.7v = 75ksps +class MCP3008 final : public Component, + public spi::SPIDevice { // Running at the slowest max speed supported by + // the mcp3008. 2.7v = 75ksps public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.h b/esphome/components/mcp3008/sensor/mcp3008_sensor.h index 9267f80ea8..d72d521f65 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.h +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.h @@ -8,10 +8,10 @@ namespace esphome::mcp3008 { -class MCP3008Sensor : public PollingComponent, - public sensor::Sensor, - public voltage_sampler::VoltageSampler, - public Parented { +class MCP3008Sensor final : public PollingComponent, + public sensor::Sensor, + public voltage_sampler::VoltageSampler, + public Parented { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void set_pin(uint8_t pin) { this->pin_ = pin; } diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 5351d6a2cb..33abbe847a 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -31,7 +31,7 @@ float MCP3204::read_data(uint8_t pin, bool differential) { this->disable(); uint16_t digital_value = encode_uint16(b0, b1) >> 4; - return float(digital_value) / 4096.000 * this->reference_voltage_; // in V + return float(digital_value) / 4096.000f * this->reference_voltage_; // in V } } // namespace esphome::mcp3204 diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 8ce592f386..6b835b67df 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -6,9 +6,9 @@ namespace esphome::mcp3204 { -class MCP3204 : public Component, - public spi::SPIDevice { +class MCP3204 final : public Component, + public spi::SPIDevice { public: MCP3204() = default; diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 5fe5f54d1b..54835c232b 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -9,10 +9,10 @@ namespace esphome::mcp3204 { -class MCP3204Sensor : public PollingComponent, - public Parented, - public sensor::Sensor, - public voltage_sampler::VoltageSampler { +class MCP3204Sensor final : public PollingComponent, + public Parented, + public sensor::Sensor, + public voltage_sampler::VoltageSampler { public: MCP3204Sensor(uint8_t pin, bool differential_mode) : pin_(pin), differential_mode_(differential_mode) {} diff --git a/esphome/components/mcp3221/mcp3221_sensor.h b/esphome/components/mcp3221/mcp3221_sensor.h index deef14e14d..38b62c609f 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.h +++ b/esphome/components/mcp3221/mcp3221_sensor.h @@ -10,10 +10,10 @@ namespace esphome::mcp3221 { -class MCP3221Sensor : public sensor::Sensor, - public PollingComponent, - public voltage_sampler::VoltageSampler, - public i2c::I2CDevice { +class MCP3221Sensor final : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public i2c::I2CDevice { public: void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } void update() override; diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 4573553664..e83a6847d6 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::INCREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); @@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::DECREMENT); - auto err = this->write(&this->address_, reg); + auto err = this->write(®, 1); if (err != i2c::ERROR_OK) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 3a76f855b8..a577a4b482 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -57,7 +57,7 @@ enum class Mcp4461TerminalIdx : uint8_t { MCP4461_TERMINAL_0 = 0, MCP4461_TERMIN class Mcp4461Wiper; // Mcp4461Component -class Mcp4461Component : public Component, public i2c::I2CDevice { +class Mcp4461Component final : public Component, public i2c::I2CDevice { public: Mcp4461Component(bool disable_wiper_0, bool disable_wiper_1, bool disable_wiper_2, bool disable_wiper_3) : wiper_0_disabled_(disable_wiper_0), diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 6912ad5f36..3892372cab 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -29,7 +29,9 @@ void Mcp4461Wiper::write_state(float state) { } } -float Mcp4461Wiper::read_state() { return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0); } +float Mcp4461Wiper::read_state() { + return (static_cast(this->parent_->get_wiper_level_(this->wiper_)) / 256.0f); +} float Mcp4461Wiper::update_state() { this->state_ = this->read_state(); diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 73eadceb50..20d81d825a 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4461 { -class Mcp4461Wiper : public output::FloatOutput, public Parented { +class Mcp4461Wiper final : public output::FloatOutput, public Parented { public: Mcp4461Wiper(Mcp4461Component *parent, Mcp4461WiperIdx wiper) : parent_(parent), wiper_(wiper) {} /// @brief Set level of wiper diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index a32527c725..8e94623de3 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -24,7 +24,8 @@ void MCP4725::dump_config() { // https://learn.sparkfun.com/tutorials/mcp4725-digital-to-analog-converter-hookup-guide?_ga=2.176055202.1402343014.1607953301-893095255.1606753886 void MCP4725::write_state(float state) { - const uint16_t value = (uint16_t) round(state * (pow(2, MCP4725_RES) - 1)); + constexpr uint16_t max_value = (1U << MCP4725_RES) - 1; + const uint16_t value = (uint16_t) roundf(state * max_value); this->write_byte_16(64, value << 4); } diff --git a/esphome/components/mcp4725/mcp4725.h b/esphome/components/mcp4725/mcp4725.h index 1acefc3ee4..a0838dc33e 100644 --- a/esphome/components/mcp4725/mcp4725.h +++ b/esphome/components/mcp4725/mcp4725.h @@ -4,11 +4,12 @@ #include "esphome/core/component.h" #include "esphome/components/i2c/i2c.h" -static const uint8_t MCP4725_ADDR = 0x60; -static const uint8_t MCP4725_RES = 12; - namespace esphome::mcp4725 { -class MCP4725 : public Component, public output::FloatOutput, public i2c::I2CDevice { + +static constexpr uint8_t MCP4725_ADDR = 0x60; +static constexpr uint8_t MCP4725_RES = 12; + +class MCP4725 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp4728/mcp4728.h b/esphome/components/mcp4728/mcp4728.h index 13076b3c4c..e7511e5237 100644 --- a/esphome/components/mcp4728/mcp4728.h +++ b/esphome/components/mcp4728/mcp4728.h @@ -38,7 +38,7 @@ struct DACInputData { class MCP4728Channel; /// MCP4728 float output component. -class MCP4728Component : public Component, public i2c::I2CDevice { +class MCP4728Component final : public Component, public i2c::I2CDevice { public: MCP4728Component(bool store_in_eeprom) : store_in_eeprom_(store_in_eeprom) {} diff --git a/esphome/components/mcp4728/output/mcp4728_output.h b/esphome/components/mcp4728/output/mcp4728_output.h index 3ea65ecc7b..827ce1517d 100644 --- a/esphome/components/mcp4728/output/mcp4728_output.h +++ b/esphome/components/mcp4728/output/mcp4728_output.h @@ -7,7 +7,7 @@ namespace esphome::mcp4728 { -class MCP4728Channel : public output::FloatOutput { +class MCP4728Channel final : public output::FloatOutput { public: MCP4728Channel(MCP4728Component *parent, MCP4728ChannelIdx channel, MCP4728Vref vref, MCP4728Gain gain, MCP4728PwrDown pwrdown) diff --git a/esphome/components/mcp47a1/mcp47a1.h b/esphome/components/mcp47a1/mcp47a1.h index da9794e5aa..b72c125574 100644 --- a/esphome/components/mcp47a1/mcp47a1.h +++ b/esphome/components/mcp47a1/mcp47a1.h @@ -6,7 +6,7 @@ namespace esphome::mcp47a1 { -class MCP47A1 : public Component, public output::FloatOutput, public i2c::I2CDevice { +class MCP47A1 final : public Component, public output::FloatOutput, public i2c::I2CDevice { public: void dump_config() override; void write_state(float state) override; diff --git a/esphome/components/mcp9600/mcp9600.h b/esphome/components/mcp9600/mcp9600.h index b7c0c834ab..523c9ace2f 100644 --- a/esphome/components/mcp9600/mcp9600.h +++ b/esphome/components/mcp9600/mcp9600.h @@ -17,7 +17,7 @@ enum MCP9600ThermocoupleType : uint8_t { MCP9600_THERMOCOUPLE_TYPE_R = 0b111, }; -class MCP9600Component : public PollingComponent, public i2c::I2CDevice { +class MCP9600Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mcp9808/mcp9808.h b/esphome/components/mcp9808/mcp9808.h index 89530d9ed0..b4ae51bf6f 100644 --- a/esphome/components/mcp9808/mcp9808.h +++ b/esphome/components/mcp9808/mcp9808.h @@ -6,7 +6,7 @@ namespace esphome::mcp9808 { -class MCP9808Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class MCP9808Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 10c81aac39..17e3665f50 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -5,7 +5,7 @@ namespace esphome::md5 { -#if defined(USE_ARDUINO) && !defined(USE_RP2040) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_ESP32) void MD5Digest::init() { memset(this->digest_, 0, 16); MD5Init(&this->ctx_); @@ -14,7 +14,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { MD5Update(&this->ctx_, data, len); } void MD5Digest::calculate() { MD5Final(this->digest_, &this->ctx_); } -#endif // USE_ARDUINO && !USE_RP2040 +#endif // USE_ARDUINO && !USE_RP2 #ifdef USE_ESP32 void MD5Digest::init() { @@ -27,7 +27,7 @@ void MD5Digest::add(const uint8_t *data, size_t len) { esp_rom_md5_update(&this- void MD5Digest::calculate() { esp_rom_md5_final(this->digest_, &this->ctx_); } #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 void MD5Digest::init() { memset(this->digest_, 0, 16); br_md5_init(&this->ctx_); @@ -36,7 +36,7 @@ void MD5Digest::init() { void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_, data, len); } void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } -#endif // USE_RP2040 +#endif // USE_RP2 #ifdef USE_HOST MD5Digest::~MD5Digest() { diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index 5e841edd83..ff0f2852c8 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -19,7 +19,7 @@ #define MD5_CTX_TYPE md5_context_t #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #define MD5_CTX_TYPE br_md5_context #endif diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 2de67542b2..3670098bcf 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -70,11 +70,11 @@ def _require_network_interface(config: ConfigType) -> ConfigType: window. Reject at config time rather than silently producing a component that never initializes. """ - if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): return config full_config = fv.full_config.get() has_wifi = "wifi" in full_config - has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + has_ethernet = CORE.is_rp2 and "ethernet" in full_config if not (has_wifi or has_ethernet): options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" raise cv.Invalid( @@ -192,18 +192,18 @@ async def to_code(config): if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("LEAmDNS", None) # Subscribe to the network IP state listener(s) so MDNS.update() is only # scheduled during the probe+announce phase. Same on_ip_state() override # serves both WiFi and Ethernet (signatures match). - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: if "wifi" in CORE.config: from esphome.components import wifi wifi.request_wifi_ip_state_listener() - if CORE.is_rp2040 and "ethernet" in CORE.config: + if CORE.is_rp2 and "ethernet" in CORE.config: from esphome.components import ethernet ethernet.request_ethernet_ip_state_listener() @@ -274,7 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "mdns_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "mdns_host.cpp": {PlatformFramework.HOST_NATIVE}, - "mdns_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "mdns_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "mdns_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9bf27e71e4..fa39e86ed0 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi auto &services = services_storage; #endif -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT #ifdef USE_MDNS_STORE_SERVICES get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; @@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi platform_register(this, services); } -void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf) { +void MDNSComponent::compile_records_(StaticVector &services, + const char *mac_address_buf, const char *config_hash_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. +#ifdef USE_MDNS_DEVICE_INFO_TXT + MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); + MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); + MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); +#endif + #ifdef USE_API MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib"); MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); - MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash"); - MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac"); MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform"); MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board"); MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network"); @@ -100,14 +103,20 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); txt_count++; // api_encryption or api_encryption_supported +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + txt_count++; // api_provisioning + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME txt_count += 2; // project_name and project_version @@ -136,11 +145,14 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx().has_psk(); - const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; + const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); +#ifndef USE_API_NOISE_PSK_FROM_YAML + if (!api_has_psk) { + // Unprovisioned device without a YAML key: advertise that the encryption + // key can be provisioned over a zero-PSK Noise connection. Gated on the + // YAML define so this survives the plaintext removal in 2027.2.0. + MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning"); + MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk"); + txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)}); + } +#endif #endif #ifdef ESPHOME_PROJECT_NAME @@ -209,12 +230,18 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; +#ifndef USE_API + // Without the native API there is no _esphomelib service, so publish the + // device info here for the device builder to discover. + web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; +#endif #endif #if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \ !defined(USE_MDNS_EXTRA_SERVICES) MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); - MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version"); // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works @@ -222,7 +249,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_WEBSERVER_PORT; }; - fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}}; + fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}, + {MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}, + {MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}}; #endif } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index e981a65941..4f97e8cb99 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -7,7 +7,7 @@ #include "esphome/core/helpers.h" // On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by // IP state listener events on whichever network interface is configured. -#if (defined(USE_ESP8266) || defined(USE_RP2040)) && \ +#if (defined(USE_ESP8266) || defined(USE_RP2)) && \ ((defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) || \ (defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS))) #include "esphome/components/network/ip_address.h" @@ -22,6 +22,15 @@ #endif #endif +// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service +// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one). +// When neither applies (only prometheus, sendspin or user-defined services are configured), no +// device info records are published and the buffers below are not needed. +#if defined(USE_API) || defined(USE_WEBSERVER) || \ + (!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES)) +#define USE_MDNS_DEVICE_INFO_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -136,7 +145,7 @@ class MDNSComponent final : public Component StaticVector dynamic_txt_values_; #endif -#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) +#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES) /// Fixed buffer for MAC address (only needed when services are stored) char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; /// Fixed buffer for config hash hex string (only needed when services are stored) @@ -145,12 +154,12 @@ class MDNSComponent final : public Component #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif -#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) +#if defined(USE_RP2) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif - void compile_records_(StaticVector &services, char *mac_address_buf, - char *config_hash_buf); + void compile_records_(StaticVector &services, const char *mac_address_buf, + const char *config_hash_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 1e66a10df0..c5d849df26 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -12,7 +12,7 @@ namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES -#ifdef USE_API +#ifdef USE_MDNS_DEVICE_INFO_TXT get_mac_address_into_buffer(this->mac_address_); char *mac_ptr = this->mac_address_; format_hex_to(this->config_hash_str_, App.get_config_hash()); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2.cpp similarity index 94% rename from esphome/components/mdns/mdns_rp2040.cpp rename to esphome/components/mdns/mdns_rp2.cpp index f5848893a3..7eaac594fb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -1,5 +1,5 @@ #include "esphome/core/defines.h" -#if defined(USE_RP2040) && defined(USE_MDNS) +#if defined(USE_RP2) && defined(USE_MDNS) #include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" @@ -17,7 +17,7 @@ namespace esphome::mdns { -static void register_rp2040(MDNSComponent *, StaticVector &services) { +static void register_rp2(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -82,7 +82,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: return; } if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); + this->setup_buffers_and_register_(register_rp2); this->initialized_ = true; } else { MDNS.notifyAPChange(); diff --git a/esphome/components/mdns/mdns_zephyr.cpp b/esphome/components/mdns/mdns_zephyr.cpp index 0b2fd9e62b..e23f3ac027 100644 --- a/esphome/components/mdns/mdns_zephyr.cpp +++ b/esphome/components/mdns/mdns_zephyr.cpp @@ -2,13 +2,20 @@ #if defined(USE_ZEPHYR) && defined(USE_MDNS) #include "mdns_component.h" -#include "esphome/core/log.h" namespace esphome::mdns { -static const char *const TAG = "mdns.zephyr"; +#ifdef USE_MDNS_STORE_SERVICES +// Zephyr has no local IP mDNS responder. When a consumer (ex. the OpenThread SRP +// client) enables service storage, it reads the compiled records via get_services() +// and advertises them itself. We only need to compile and store the records here. +static void register_zephyr(MDNSComponent *, StaticVector &) {} -void MDNSComponent::setup() { ESP_LOGW(TAG, "mDNS is not implemented for Zephyr"); } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_zephyr); } +#else +// No responder and nothing consuming the records, so skip the boot-time compile. +void MDNSComponent::setup() {} +#endif void MDNSComponent::on_shutdown() {} diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 9319335872..899acfefdf 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -6,7 +6,7 @@ namespace esphome::media_player { template -class MediaPlayerCommandAction : public Action, public Parented { +class MediaPlayerCommandAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, announcement); void play(const Ts &...x) override { @@ -54,7 +54,7 @@ template using ClearPlaylistAction = MediaPlayerCommandAction; template -class MediaPlayerMediaAction : public Action, public Parented { +class MediaPlayerMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, media_url) TEMPLATABLE_VALUE(bool, announcement) void play(const Ts &...x) override { @@ -70,7 +70,7 @@ using PlayMediaAction = MediaPlayerMediaAction using EnqueueMediaAction = MediaPlayerMediaAction; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } }; @@ -97,39 +97,39 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -template class IsIdleCondition : public Condition, public Parented { +template class IsIdleCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_IDLE; } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING; } }; -template class IsPausedCondition : public Condition, public Parented { +template class IsPausedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_PAUSED; } }; -template class IsAnnouncingCondition : public Condition, public Parented { +template class IsAnnouncingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING; } }; -template class IsOnCondition : public Condition, public Parented { +template class IsOnCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_ON; } }; -template class IsOffCondition : public Condition, public Parented { +template class IsOffCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == MediaPlayerState::MEDIA_PLAYER_STATE_OFF; } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_muted(); } }; diff --git a/esphome/components/mhz19/mhz19.h b/esphome/components/mhz19/mhz19.h index e577b98537..3cef3a3930 100644 --- a/esphome/components/mhz19/mhz19.h +++ b/esphome/components/mhz19/mhz19.h @@ -20,7 +20,7 @@ enum MHZ19DetectionRange { MHZ19_DETECTION_RANGE_0_10000PPM, }; -class MHZ19Component : public PollingComponent, public uart::UARTDevice { +class MHZ19Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; void update() override; @@ -49,22 +49,23 @@ class MHZ19Component : public PollingComponent, public uart::UARTDevice { MHZ19DetectionRange detection_range_{MHZ19_DETECTION_RANGE_DEFAULT}; }; -template class MHZ19CalibrateZeroAction : public Action, public Parented { +template class MHZ19CalibrateZeroAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_zero(); } }; -template class MHZ19ABCEnableAction : public Action, public Parented { +template class MHZ19ABCEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_enable(); } }; -template class MHZ19ABCDisableAction : public Action, public Parented { +template class MHZ19ABCDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->abc_disable(); } }; -template class MHZ19DetectionRangeSetAction : public Action, public Parented { +template +class MHZ19DetectionRangeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(MHZ19DetectionRange, detection_range) diff --git a/esphome/components/micro_wake_word/automation.h b/esphome/components/micro_wake_word/automation.h index e3b35583fb..59dfc624fa 100644 --- a/esphome/components/micro_wake_word/automation.h +++ b/esphome/components/micro_wake_word/automation.h @@ -7,22 +7,22 @@ namespace esphome::micro_wake_word { -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class EnableModelAction : public Action { +template class EnableModelAction final : public Action { public: explicit EnableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->enable(); } @@ -31,7 +31,7 @@ template class EnableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class DisableModelAction : public Action { +template class DisableModelAction final : public Action { public: explicit DisableModelAction(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} void play(const Ts &...x) override { this->wake_word_model_->disable(); } @@ -40,7 +40,7 @@ template class DisableModelAction : public Action { WakeWordModel *wake_word_model_; }; -template class ModelIsEnabledCondition : public Condition { +template class ModelIsEnabledCondition final : public Condition { public: explicit ModelIsEnabledCondition(WakeWordModel *wake_word_model) : wake_word_model_(wake_word_model) {} bool check(const Ts &...x) override { return this->wake_word_model_->is_enabled(); } diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index e4c590a423..aebb5b2595 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -31,10 +31,10 @@ enum State { STOPPED, }; -class MicroWakeWord : public Component +class MicroWakeWord final : public Component #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 0258dbb53c..9f8f66ee02 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaButton : public Component, public button::Button, public MicroNovaBaseListener { +class MicroNovaButton final : public Component, public button::Button, public MicroNovaBaseListener { public: MicroNovaButton(MicroNova *m) : MicroNovaBaseListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index 58cca30b83..c57286db6c 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -58,7 +58,7 @@ class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent ///////////////////////////////////////////////////////////////////// // Main component class -class MicroNova : public Component, public uart::UARTDevice { +class MicroNova final : public Component, public uart::UARTDevice { public: MicroNova(GPIOPin *enable_rx_pin) : enable_rx_pin_(enable_rx_pin) {} diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 73666b632b..91e4253b46 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaNumber : public number::Number, public MicroNovaListener { +class MicroNovaNumber final : public number::Number, public MicroNovaListener { public: MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index f3b06d140e..8263ad0948 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -5,7 +5,7 @@ namespace esphome::micronova { -class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { +class MicroNovaSensor final : public sensor::Sensor, public MicroNovaListener { public: MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index fee3c73976..4a4d5eb721 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,7 +6,7 @@ namespace esphome::micronova { -class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { +class MicroNovaSwitch final : public switch_::Switch, public MicroNovaListener { public: MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index 6918a372e8..2de93404a5 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -17,7 +17,7 @@ static const char *const STOVE_STATES[11] = {"Off", "No ignition alarm", "Undefined alarm"}; -class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { +class MicroNovaTextSensor final : public text_sensor::TextSensor, public MicroNovaListener { public: MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} void dump_config() override; diff --git a/esphome/components/microphone/automation.h b/esphome/components/microphone/automation.h index 1dfd91f903..c28616a290 100644 --- a/esphome/components/microphone/automation.h +++ b/esphome/components/microphone/automation.h @@ -7,34 +7,34 @@ namespace esphome::microphone { -template class CaptureAction : public Action, public Parented { +template class CaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopCaptureAction : public Action, public Parented { +template class StopCaptureAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->stop(); } }; -template class MuteAction : public Action, public Parented { +template class MuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(true); } }; -template class UnmuteAction : public Action, public Parented { +template class UnmuteAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_mute_state(false); } }; -class DataTrigger : public Trigger &> { +class DataTrigger final : public Trigger &> { public: explicit DataTrigger(Microphone *mic) { mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); } }; -template class IsCapturingCondition : public Condition, public Parented { +template class IsCapturingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsMutedCondition : public Condition, public Parented { +template class IsMutedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_mute_state(); } }; diff --git a/esphome/components/microphone/microphone_source.h b/esphome/components/microphone/microphone_source.h index c3c675e854..7be3b8cdb5 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -13,7 +13,7 @@ namespace esphome::microphone { static const int32_t MAX_GAIN_FACTOR = 64; -class MicrophoneSource { +class MicrophoneSource final { /* * @brief Helper class that handles converting raw microphone data to a requested format. * Components requesting microphone audio should register a callback through this class instead of registering a diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index d99d4fd772..14a73bc15f 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -71,10 +71,10 @@ void MICS4514Component::update() { float co = 0.0f; if (red_f > 3.4f) { co = 0.0; - } else if (red_f < 0.01) { + } else if (red_f < 0.01f) { co = 1000.0; } else { - co = 4.2 / pow(red_f, 1.2); + co = 4.2f / powf(red_f, 1.2f); } this->carbon_monoxide_sensor_->publish_state(co); } @@ -84,47 +84,47 @@ void MICS4514Component::update() { if (ox_f < 0.3f) { nitrogendioxide = 0.0; } else { - nitrogendioxide = 0.164 * pow(ox_f, 0.975); + nitrogendioxide = 0.164f * powf(ox_f, 0.975f); } this->nitrogen_dioxide_sensor_->publish_state(nitrogendioxide); } if (this->methane_sensor_ != nullptr) { float methane = 0.0f; - if (red_f > 0.9f || red_f < 0.5) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.5f) { // outside the range->unlikely methane = 0.0; } else { - methane = 630 / pow(red_f, 4.4); + methane = 630 / powf(red_f, 4.4f); } this->methane_sensor_->publish_state(methane); } if (this->ethanol_sensor_ != nullptr) { float ethanol = 0.0f; - if (red_f > 1.0f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 1.0f || red_f < 0.02f) { // outside the range->unlikely ethanol = 0.0; } else { - ethanol = 1.52 / pow(red_f, 1.55); + ethanol = 1.52f / powf(red_f, 1.55f); } this->ethanol_sensor_->publish_state(ethanol); } if (this->hydrogen_sensor_ != nullptr) { float hydrogen = 0.0f; - if (red_f > 0.9f || red_f < 0.02) { // outside the range->unlikely + if (red_f > 0.9f || red_f < 0.02f) { // outside the range->unlikely hydrogen = 0.0; } else { - hydrogen = 0.85 / pow(red_f, 1.75); + hydrogen = 0.85f / powf(red_f, 1.75f); } this->hydrogen_sensor_->publish_state(hydrogen); } if (this->ammonia_sensor_ != nullptr) { float ammonia = 0.0f; - if (red_f > 0.98f || red_f < 0.2532) { // outside the ammonia range->unlikely + if (red_f > 0.98f || red_f < 0.2532f) { // outside the ammonia range->unlikely ammonia = 0.0; } else { - ammonia = 0.9 / pow(red_f, 4.6); + ammonia = 0.9f / powf(red_f, 4.6f); } this->ammonia_sensor_->publish_state(ammonia); } diff --git a/esphome/components/mics_4514/mics_4514.h b/esphome/components/mics_4514/mics_4514.h index 4f8b970f06..d8c422808a 100644 --- a/esphome/components/mics_4514/mics_4514.h +++ b/esphome/components/mics_4514/mics_4514.h @@ -7,7 +7,7 @@ namespace esphome::mics_4514 { -class MICS4514Component : public PollingComponent, public i2c::I2CDevice { +class MICS4514Component final : public PollingComponent, public i2c::I2CDevice { SUB_SENSOR(carbon_monoxide) SUB_SENSOR(nitrogen_dioxide) SUB_SENSOR(methane) diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 6ed5a82ff5..bea6c2eadb 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,7 +21,7 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; -class AirConditioner : public ApplianceBase, public climate::Climate { +class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index c954b45033..4a75464b90 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -260,6 +260,11 @@ async def power_inv_to_code(var, config, args): pass +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "midea", baud_rate=9600, require_rx=True, require_tx=True +) + + async def to_code(config): var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/midea_ir/midea_ir.h b/esphome/components/midea_ir/midea_ir.h index dd883172d4..e89eaf0110 100644 --- a/esphome/components/midea_ir/midea_ir.h +++ b/esphome/components/midea_ir/midea_ir.h @@ -11,7 +11,7 @@ const uint8_t MIDEA_TEMPC_MAX = 30; // Celsius const uint8_t MIDEA_TEMPF_MIN = 62; // Fahrenheit const uint8_t MIDEA_TEMPF_MAX = 86; // Fahrenheit -class MideaIR : public climate_ir::ClimateIR { +class MideaIR final : public climate_ir::ClimateIR { public: MideaIR() : climate_ir::ClimateIR( diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 63c3ddf9c2..3f73f96327 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -26,16 +26,22 @@ from esphome.const import ( CONF_OFFSET_HEIGHT, CONF_OFFSET_WIDTH, CONF_PAGES, + CONF_RESET_PIN, CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import TimePeriod +from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor LOGGER = cv.logging.getLogger(__name__) +CONF_TRANSFORMS = "transforms" + +# All axis transforms a model may support, in the order they appear in the schema. +ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY) + ColorOrder = display_ns.enum("ColorMode") NOP = 0x00 @@ -302,7 +308,8 @@ class DriverChip: """ A class representing a MIPI DBI driver chip model. The parameters supplied as defaults will be used to provide default values for the display configuration. - Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes. + Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model + supports; by default all three are available. """ models: dict[str, Self] = {} @@ -323,6 +330,9 @@ class DriverChip: - defaults.get(CONF_OFFSET_WIDTH, 0) - defaults.get(CONF_PAD_WIDTH, 0) ) + elif defaults[CONF_WIDTH] > defaults[CONF_NATIVE_WIDTH]: + defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] + else: native_width = ( defaults.get(CONF_WIDTH, 0) @@ -338,6 +348,8 @@ class DriverChip: - defaults.get(CONF_OFFSET_HEIGHT, 0) - defaults.get(CONF_PAD_HEIGHT, 0) ) + elif defaults[CONF_HEIGHT] > defaults[CONF_NATIVE_HEIGHT]: + defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] else: native_height = ( defaults.get(CONF_HEIGHT, 0) @@ -382,11 +394,15 @@ class DriverChip: """ Return the available transforms for this model. """ + if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None: + return transforms if self.get_default("no_transform", False): return set() if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED: return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} - return {CONF_MIRROR_X, CONF_MIRROR_Y} + raise ValueError( + "Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead" + ) def has_hardware_transform(self, config) -> bool: """ @@ -528,17 +544,31 @@ class DriverChip: transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform - def swap_xy_schema(self): - uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED + def transform_schema(self): + """ + Build the schema for the ``transform`` config option of this model. - def validator(value): - if value: - raise cv.Invalid("Axis swapping not supported by this model") - return cv.boolean(value) + Each transform the model supports is a required boolean. A transform the model does not + support may be omitted or set to ``false``; setting it to ``true`` reports a clear error + naming the unsupported transform instead of a generic "extra keys not allowed". + """ + supported = self.transforms - if uses_swap: - return {cv.Required(CONF_SWAP_XY): cv.boolean} - return {cv.Optional(CONF_SWAP_XY, default=False): validator} + def unsupported(name): + def validator(value): + if cv.boolean(value): + raise cv.Invalid(f"'{name}' is not supported by this model") + return False + + return validator + + schema = {} + for name in ALL_TRANSFORMS: + if name in supported: + schema[cv.Required(name)] = cv.boolean + else: + schema[cv.Optional(name, default=False)] = unsupported(name) + return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True)) def get_madctl(self, transform: dict, config: dict) -> int: """ @@ -572,12 +602,15 @@ class DriverChip: """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: + def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence MADCTL will be set if add_madctl is True + If add_reset is True, a reset is prepended: a software reset when no reset pin + is configured (and the model doesn't skip it), followed by a settling delay that + both a software and a hardware reset require. Returns the init sequence """ sequence = list(self.initsequence or ()) @@ -586,6 +619,15 @@ class DriverChip: # Ensure each command is a tuple sequence = [x if isinstance(x, tuple) else (x,) for x in sequence] + if add_reset: + reset: list = [] + # A software reset is only needed when there is no hardware reset pin. + if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"): + reset.append((SWRESET,)) + # Both a software and a hardware reset need a settling delay before further commands. + reset.append(delay(10)) + sequence = reset + sequence + # Set pixel format if not already in the custom sequence pixel_mode = config[CONF_PIXEL_MODE] if not isinstance(pixel_mode, int): @@ -606,13 +648,47 @@ class DriverChip: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): + # A zero delay will delay until 120ms after reset + sequence.append(delay(0)) sequence.append((SLPOUT,)) + sequence.append(delay(10)) sequence.append((DISPON,)) + # Add a delay here because additional commands may be added after this at runtime. + sequence.append(delay(10)) # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed return flatten_sequence(sequence) + def check_requirements(self) -> None: + """ + Raise a friendly error if any component this model requires is not configured. + + This runs during schema validation (before ID references are resolved) so that a + model whose default pins live on a pin expander reports the missing expander clearly + instead of a cryptic "Couldn't find ID" from the unresolved pin reference. + + Also logs a warning if the model is deprecated. + """ + if deprecation_reason := self.get_default("deprecation_reason"): + LOGGER.warning( + "Display model %s is deprecated: %s", self.name, deprecation_reason + ) + if requirements := self.get_default("requires", set()): + # ``raw_config`` is populated before any component schema runs during a real + # validation, so presence of a required component is simply a top-level key. + # When it is absent (e.g. a unit test that invokes the schema directly) there + # is no config to check against, so skip. + global_config = CORE.raw_config + if global_config is None: + return + missing = {x for x in requirements if x not in global_config} + if missing: + reqstr = ", ".join(f"'{x}'" for x in sorted(missing)) + raise cv.Invalid( + f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured" + ) + def requires_buffer(config) -> bool: """ diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 896140b4b1..e5bb3d413d 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -41,24 +41,21 @@ from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, CONF_COLOR_ORDER, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, - CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) from esphome.final_validate import full_config from . import mipi_dsi_ns, models +from .models import DsiDriverChip # Currently only ESP32-P4 is supported, so esp_ldo and psram are required DEPENDENCIES = ["esp32", "esp_ldo", "psram"] @@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness") CONF_LANE_BIT_RATE = "lane_bit_rate" CONF_LANES = "lanes" -DriverChip("CUSTOM") +DsiDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -90,19 +87,7 @@ COLOR_DEPTHS = { def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - model.defaults[CONF_SWAP_XY] = cv.UNDEFINED - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - cv.Optional(CONF_SWAP_XY): cv.invalid( - "Axis swapping not supported by DSI displays" - ), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -172,6 +157,7 @@ def _config_schema(config): )(config) config = model_schema(config)(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index c99f69989a..7bf2feb73c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,7 +35,7 @@ const uint8_t MADCTL_MV = 0x20; // row/column swap const uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally const uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically -class MipiDsi : public display::Display { +class MipiDsi final : public display::Display { public: MipiDsi(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) : width_(width), height_(height), color_depth_(color_depth), pixel_mode_(pixel_mode) {} diff --git a/esphome/components/mipi_dsi/models/__init__.py b/esphome/components/mipi_dsi/models/__init__.py index e69de29bb2..3f7f8370a3 100644 --- a/esphome/components/mipi_dsi/models/__init__.py +++ b/esphome/components/mipi_dsi/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class DsiDriverChip(DriverChip): + """A driver chip for MIPI DSI displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index db13c7f6cc..31a2b0ce1a 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "JC1060P470", width=1024, height=600, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=12, pclk_frequency="54MHz", lane_bit_rate="750Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00), @@ -46,7 +44,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) # * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC4880P443", width=480, height=800, @@ -58,7 +56,6 @@ DriverChip( vsync_front_porch=166, pclk_frequency="34MHz", lane_bit_rate="500Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=5, initsequence=[ @@ -111,7 +108,7 @@ DriverChip( # * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) # * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20) # ---------------------------------------------------------------------------------------------------------------------- -DriverChip( +DsiDriverChip( "JC8012P4A1", width=800, height=1280, @@ -123,7 +120,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="1Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 53fac9b534..5b07229ec7 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -1,8 +1,7 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off -DriverChip( +DsiDriverChip( "M5STACK-TAB5", height=1280, width=720, @@ -14,7 +13,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="60MHz", lane_bit_rate="730Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 @@ -56,8 +54,8 @@ DriverChip( ], ) -DriverChip( - "M5STACK-TAB5-V2", +TAB5_ST7123 = DsiDriverChip( + "M5STACK-TAB5-ST7123", height=1280, width=720, hsync_back_porch=40, @@ -68,7 +66,6 @@ DriverChip( vsync_front_porch=220, pclk_frequency="80MHz", lane_bit_rate="960Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0x01,), @@ -97,3 +94,58 @@ DriverChip( (0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF), ], ) + +TAB5_ST7123.extend( + "M5STACK-TAB5-V2", + deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead." +) + +# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123. +# The two are distinguishable at runtime by the touch controller firmware version (the M5 +# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121 +# units must select this model explicitly. Values taken from M5's factory source +# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table). +DsiDriverChip( + "M5STACK-TAB5-ST7121", + height=1280, + width=720, + hsync_back_porch=40, + hsync_pulse_width=2, + hsync_front_porch=40, + vsync_back_porch=24, + vsync_pulse_width=20, + vsync_front_porch=200, + pclk_frequency="70MHz", + lane_bit_rate="965Mbps", + color_order="RGB", + initsequence=[ + (0x01,), + (0x60, 0x71, 0x21, 0xA2), + (0x60, 0x71, 0x21, 0xA3), + (0x60, 0x71, 0x21, 0xA4), + (0x78, 0x21), + (0x79, 0xEF), + (0xA4, 0x31), + (0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A), + (0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43), + (0xBF, 0xA7, 0xA7), + (0xA5, 0xF0, 0x03), + (0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80), + (0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21), + (0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF), + (0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00), + (0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79), + (0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D), + (0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF), + (0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00), + (0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00), + (0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E), + (0x75, 0x03, 0x04), + (0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51), + (0xE1, 0x0C, 0x0C), + (0xEA, 0x15, 0x00, 0x01), + (0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF), + (0x60, 0x71, 0x21, 0x00), + ], +) diff --git a/esphome/components/mipi_dsi/models/seeed.py b/esphome/components/mipi_dsi/models/seeed.py index 290b0e07ee..84593b40e6 100644 --- a/esphome/components/mipi_dsi/models/seeed.py +++ b/esphome/components/mipi_dsi/models/seeed.py @@ -1,9 +1,8 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # Standalone display # Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html -DriverChip( +DsiDriverChip( "SEEED-RETERMINAL-D1001", height=1280, width=800, @@ -15,10 +14,10 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}], reset_pin={"xl9535": None, "number": 2}, + requires={"psram", "xl9535"}, initsequence=( (0xE0, 0x00), (0xE1, 0x93), diff --git a/esphome/components/mipi_dsi/models/waveshare.py b/esphome/components/mipi_dsi/models/waveshare.py index c97a0bbd02..a1702fb6a1 100644 --- a/esphome/components/mipi_dsi/models/waveshare.py +++ b/esphome/components/mipi_dsi/models/waveshare.py @@ -1,12 +1,11 @@ -from esphome.components.mipi import DriverChip -import esphome.config_validation as cv +from . import DsiDriverChip # fmt: off # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1 # Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage -JD9365_10_1_DSI_TOUCH_A = DriverChip( +JD9365_10_1_DSI_TOUCH_A = DsiDriverChip( "WAVESHARE-P4-NANO-10.1", height=1280, width=800, @@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703 # Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO -DriverChip( +DsiDriverChip( "WAVESHARE-P4-86-PANEL", height=720, width=720, @@ -77,7 +75,6 @@ DriverChip( vsync_front_porch=20, pclk_frequency="38MHz", lane_bit_rate="480Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", reset_pin=27, initsequence=[ @@ -109,7 +106,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B -DriverChip( +DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B", height=600, width=1024, @@ -139,7 +136,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C -JD9365_3_4_DSI_TOUCH_C = DriverChip( +JD9365_3_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C", height=800, width=800, @@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C -JD9365_4_DSI_TOUCH_C = DriverChip( +JD9365_4_DSI_TOUCH_C = DsiDriverChip( "WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C", height=720, width=720, @@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip( vsync_front_porch=24, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365 # Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-8-DSI-TOUCH-A", height=1280, width=800, @@ -267,7 +262,6 @@ DriverChip( vsync_front_porch=30, pclk_frequency="80MHz", lane_bit_rate="1.5Gbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ (0xE0, 0x00), # select userpage @@ -304,7 +298,7 @@ DriverChip( # Source for parameters and initsequence: # https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c # Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A -DriverChip( +DsiDriverChip( "WAVESHARE-7-DSI-TOUCH-A", height=1280, width=720, diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 1eacc31fc5..ebe930d37a 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -18,6 +18,8 @@ from esphome.components.mipi import ( CONF_HSYNC_BACK_PORCH, CONF_HSYNC_FRONT_PORCH, CONF_HSYNC_PULSE_WIDTH, + CONF_PCLK_FREQUENCY, + CONF_PCLK_INVERTED, CONF_PCLK_PIN, CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -34,9 +36,11 @@ from esphome.components.mipi import ( power_of_two, requires_buffer, ) -from esphome.components.rpi_dpi_rgb.display import ( - CONF_PCLK_FREQUENCY, - CONF_PCLK_INVERTED, +from esphome.components.spi import ( + CONF_SPI_MODE, + SPI_DATA_RATE_SCHEMA, + SPI_MODE_OPTIONS, + SPIComponent, ) import esphome.config_validation as cv from esphome.const import ( @@ -48,7 +52,6 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -57,8 +60,6 @@ from esphome.const import ( CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_NUMBER, CONF_RED, @@ -72,10 +73,10 @@ from esphome.const import ( ) from esphome.final_validate import full_config -from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent from . import models +from .models import RgbDriverChip -DEPENDENCIES = ["esp32", "psram"] +DEPENDENCIES = ["esp32"] mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb") mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component) @@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode") DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema -DriverChip("CUSTOM") +RgbDriverChip("CUSTOM") # Import all models dynamically from the models package @@ -120,16 +121,7 @@ def data_pin_set(length): def model_schema(config): model = MODELS[config[CONF_MODEL].upper()] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list if model.initsequence is None: # Custom model requires an init sequence @@ -235,6 +227,7 @@ def _config_schema(config): only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), )(config) model = MODELS[config[CONF_MODEL].upper()] + model.check_requirements() width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( model.get_dimensions(config) ) diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index dfa8a36e1a..1480004833 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -98,9 +98,9 @@ class MipiRgb : public display::Display { }; #ifdef USE_SPI -class MipiRgbSpi : public MipiRgb, - public spi::SPIDevice { +class MipiRgbSpi final : public MipiRgb, + public spi::SPIDevice { public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py new file mode 100644 index 0000000000..9e3fe2a476 --- /dev/null +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -0,0 +1,14 @@ +from esphome.components.mipi import DriverChip +from esphome.const import CONF_SWAP_XY + + +class RgbDriverChip(DriverChip): + """A driver chip for MIPI RGB displays.""" + + @property + def transforms(self) -> set[str]: + """ + Return the set of transformations supported by this driver chip. + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + """ + return super().transforms - {CONF_SWAP_XY} diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index 915b8beda0..c0aaf0a3d2 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -5,6 +5,7 @@ st7701s.extend( width=480, height=480, data_rate="2MHz", + requires={"psram"}, cs_pin=39, de_pin=18, hsync_pin=16, diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index c0e91cd8ae..4e0615b439 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -1,5 +1,3 @@ -from esphome.config_validation import UNDEFINED - from .st7701s import ST7701S # fmt: off @@ -8,10 +6,10 @@ ST7701S( width=480, height=480, invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 17}, reset_pin={"xl9535": None, "number": 5}, + requires={"psram", "xl9535"}, hsync_pin=39, vsync_pin=40, pclk_pin=41, @@ -57,9 +55,9 @@ t_rgb = ST7701S( height=480, pixel_mode="18bit", invert_colors=False, - swap_xy=UNDEFINED, spi_mode="MODE3", cs_pin={"xl9535": None, "number": 3}, + requires={"psram", "xl9535"}, de_pin=45, hsync_pin=47, vsync_pin=41, diff --git a/esphome/components/mipi_rgb/models/rpi.py b/esphome/components/mipi_rgb/models/rpi.py index 076d96b658..1e2a6600ee 100644 --- a/esphome/components/mipi_rgb/models/rpi.py +++ b/esphome/components/mipi_rgb/models/rpi.py @@ -1,9 +1,7 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence -DriverChip( +RgbDriverChip( "RPI", - swap_xy=UNDEFINED, initsequence=(), ) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 990a1ca4f3..a20e9d1c01 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -1,17 +1,12 @@ -from esphome.components.mipi import ( - MADCTL, - MADCTL_ML, - MADCTL_XFLIP, - MODE_BGR, - DriverChip, -) -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y +from . import RgbDriverChip + SDIR_CMD = 0xC7 -class ST7701S(DriverChip): +class ST7701S(RgbDriverChip): # The ST7701s does not use the standard MADCTL bits for x/y mirroring def add_madctl(self, sequence: list, config: dict): transform = self.get_transform(config) @@ -45,7 +40,6 @@ st7701s = ST7701S( "ST7701S", width=480, height=864, - swap_xy=UNDEFINED, hsync_front_porch=20, hsync_back_porch=10, hsync_pulse_width=10, @@ -85,6 +79,7 @@ st7701s.extend( height=480, invert_colors=True, pixel_mode="18bit", + requires={"psram"}, cs_pin=1, de_pin={ "number": 45, @@ -117,6 +112,7 @@ st7701s.extend( vsync_pulse_width=8, vsync_back_porch=20, cs_pin={"pca9554": None, "number": 4}, + requires={"psram", "pca9554"}, de_pin=18, hsync_pin=16, vsync_pin=17, @@ -134,6 +130,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=18, reset_pin=8, de_pin=17, @@ -177,6 +174,7 @@ st7701s.extend( width=480, height=480, pixel_mode="18bit", + requires={"psram"}, cs_pin=21, de_pin=39, vsync_pin=48, diff --git a/esphome/components/mipi_rgb/models/sunton.py b/esphome/components/mipi_rgb/models/sunton.py index a33625dfe4..a87d5f3c38 100644 --- a/esphome/components/mipi_rgb/models/sunton.py +++ b/esphome/components/mipi_rgb/models/sunton.py @@ -1,14 +1,13 @@ -from esphome.components.mipi import DriverChip -from esphome.config_validation import UNDEFINED +from . import RgbDriverChip # fmt: off -sunton = DriverChip( +sunton = RgbDriverChip( "ESP32-8048S070", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="12.5MHz", + requires={"psram"}, de_pin=41, hsync_pin=39, vsync_pin=40, @@ -28,7 +27,6 @@ sunton = DriverChip( sunton.extend( "ESP32-8048S050", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index cd1fc341ef..ef1a5cd2d6 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,18 +1,18 @@ -from esphome.components.mipi import DriverChip, delay -from esphome.config_validation import UNDEFINED +from esphome.components.mipi import delay +from . import RgbDriverChip from .st7701s import st7701s # fmt: off -wave_4_3 = DriverChip( +wave_4_3 = RgbDriverChip( "ESP32-S3-TOUCH-LCD-4.3", - swap_xy=UNDEFINED, initsequence=(), width=800, height=480, pclk_frequency="16MHz", reset_pin={"ch422g": None, "number": 3}, enable_pin={"ch422g": None, "number": 2}, + requires={"psram", "ch422g"}, de_pin=5, hsync_pin={"number": 46, "ignore_strapping_warning": True}, vsync_pin={"number": 3, "ignore_strapping_warning": True}, @@ -69,6 +69,7 @@ st7701s.extend( pclk_pin=41, pclk_frequency="12MHz", pclk_inverted=False, + requires={"psram"}, data_pins={ "red": [46, 3, 8, 18, 17], "green": [14, 13, 12, 11, 10, 9], @@ -80,6 +81,7 @@ st7701s.extend( "WAVESHARE-3.16-320X820", width=320, height=820, + requires={"psram"}, de_pin=40, hsync_pin=38, vsync_pin=39, diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 871736abd1..246db237b1 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -41,14 +41,11 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_ID, CONF_INIT_SEQUENCE, CONF_INVERT_COLORS, CONF_LAMBDA, - CONF_MIRROR_X, - CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, CONF_ROTATION, @@ -138,16 +135,7 @@ def denominator(config): def model_schema(config): model = MODELS[config[CONF_MODEL]] bus_mode = config[CONF_BUS_MODE] - transform = cv.Any( - cv.Schema( - { - cv.Required(CONF_MIRROR_X): cv.boolean, - cv.Required(CONF_MIRROR_Y): cv.boolean, - **model.swap_xy_schema(), - } - ), - cv.one_of(CONF_DISABLED, lower=True), - ) + transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence iseqconf = ( cv.Required(CONF_INIT_SEQUENCE) @@ -265,6 +253,7 @@ def customise_schema(config): extra=ALLOW_EXTRA, )(config) model = MODELS[config[CONF_MODEL]] + model.check_requirements() bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL) config = cv.Schema( { @@ -408,7 +397,7 @@ def get_instance(config): async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] - init_sequence = model.get_sequence(config, False) + init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) cg.add(var.set_init_sequence(init_sequence)) diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48184fa5c1..701bcd7169 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi"; // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; + +// Command codes for MIPI SPI displays. Not all currently used, kept here for reference. static constexpr uint8_t SW_RESET_CMD = 0x01; static constexpr uint8_t SLEEP_OUT = 0x11; static constexpr uint8_t NORON = 0x13; @@ -151,14 +153,11 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); - } else { - // no reset pin, send software reset command - this->write_command_(SW_RESET_CMD); + // required delay after reset is already in the init sequence, don't duplicate } // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; - delay(10); size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { @@ -170,6 +169,9 @@ class MipiSpi : public display::Display, uint8_t cmd = vec[index++]; uint8_t x = vec[index++]; if (x == DELAY_FLAG) { + if (cmd == 0) { + cmd = clamp_at_least((int) (when - millis()), 0); + } esph_log_d(TAG, "Delay %dms", cmd); delay(cmd); } else { @@ -179,24 +181,9 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - switch (cmd) { - case SLEEP_OUT: { - // are we ready, boots? - int duration = when - millis(); - if (duration > 0) { - esph_log_d(TAG, "Sleep %dms", duration); - delay(duration); - } - } break; - - default: - break; - } const auto *ptr = vec.data() + index; this->write_command_(cmd, ptr, num_args); index += num_args; - if (cmd == SLEEP_OUT) - delay(10); } } this->reset_params_(); diff --git a/esphome/components/mipi_spi/models/adafruit.py b/esphome/components/mipi_spi/models/adafruit.py index 26790b1493..cc295487eb 100644 --- a/esphome/components/mipi_spi/models/adafruit.py +++ b/esphome/components/mipi_spi/models/adafruit.py @@ -13,6 +13,7 @@ ST7789V.extend( mirror_x=True, mirror_y=True, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -25,4 +26,5 @@ ST7789V.extend( dc_pin=39, reset_pin=40, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/amoled.py b/esphome/components/mipi_spi/models/amoled.py index 32cad70ac0..8a869f2284 100644 --- a/esphome/components/mipi_spi/models/amoled.py +++ b/esphome/components/mipi_spi/models/amoled.py @@ -16,6 +16,7 @@ from esphome.components.mipi import ( delay, ) from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y DriverChip( "T-DISPLAY-S3-AMOLED", @@ -28,6 +29,7 @@ DriverChip( brightness=0xD0, color_order=MODE_RGB, no_slpout=True, # SLPOUT is in the init sequence, early + requires={"psram"}, initsequence=(SLPOUT,), ) @@ -42,6 +44,7 @@ DriverChip( data_rate="40MHz", brightness=0xD0, color_order=MODE_RGB, + requires={"psram"}, initsequence=( (PAGESEL, 4), (0x6A, 0x00), @@ -89,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend( reset_pin=13, enable_pin=9, bus_mode=TYPE_QUAD, + requires={"psram"}, ) CO5300 = DriverChip( @@ -97,6 +101,9 @@ CO5300 = DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, no_slpout=True, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + width=480, + height=480, initsequence=( (SLPOUT,), # Requires early SLPOUT (PAGESEL, 0x00), diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 5598a51073..187fcfd8c0 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -10,7 +10,6 @@ from esphome.components.mipi import ( GMCTR, GMCTRN1, GMCTRP1, - IDMOFF, IFCTR, IFMODE, INVCTR, @@ -23,7 +22,6 @@ from esphome.components.mipi import ( PWCTR5, PWSET, PWSETN, - SETEXTC, VMCTR, VMCTR1, VMCTR2, @@ -32,60 +30,6 @@ from esphome.components.mipi import ( ) from esphome.components.spi import TYPE_OCTAL -DriverChip( - "M5CORE", - width=320, - height=240, - cs_pin=14, - dc_pin=27, - reset_pin=33, - initsequence=( - (SETEXTC, 0xFF, 0x93, 0x42), - (PWCTR1, 0x12, 0x12), - (PWCTR2, 0x03), - (VMCTR1, 0xF2), - (IFMODE, 0xE0), - (0xF6, 0x01, 0x00, 0x00), - ( - GMCTRP1, - 0x00, - 0x0C, - 0x11, - 0x04, - 0x11, - 0x08, - 0x37, - 0x89, - 0x4C, - 0x06, - 0x0C, - 0x0A, - 0x2E, - 0x34, - 0x0F, - ), - ( - GMCTRN1, - 0x00, - 0x0B, - 0x11, - 0x05, - 0x13, - 0x09, - 0x33, - 0x67, - 0x48, - 0x07, - 0x0E, - 0x0B, - 0x2E, - 0x33, - 0x0F, - ), - (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), - (IDMOFF,), - ), -) ILI9341 = DriverChip( "ILI9341", mirror_x=True, @@ -174,22 +118,6 @@ 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, - cs_pin=5, - dc_pin=15, - invert_colors=True, - pixel_mode="18bit", - data_rate="40MHz", -) - DriverChip( "ILI9481", mirror_x=True, @@ -386,6 +314,7 @@ DriverChip( data_rate="40MHz", dc_pin=4, cs_pin=5, + requires={"psram"}, # reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48}, initsequence=( (0xEF, 0x03, 0x80, 0x02), @@ -451,6 +380,7 @@ DriverChip( cs_pin=5, dc_pin=4, reset_pin=48, + requires={"psram"}, initsequence=( (0xEF, 0x03, 0x80, 0x02), (0xCF, 0x00, 0xC1, 0x30), @@ -783,6 +713,7 @@ ST7796.extend( reset_pin=4, dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, + requires={"psram"}, ) ST7789V.extend( diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 854814f572..ca9adb4a72 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -1,14 +1,19 @@ from esphome.components.mipi import MODE_RGB, DriverChip from esphome.components.spi import TYPE_QUAD -import esphome.config_validation as cv -from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER +from esphome.const import ( + CONF_IGNORE_STRAPPING_WARNING, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_NUMBER, +) AXS15231 = DriverChip( "AXS15231", draw_rounding=8, - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, color_order=MODE_RGB, bus_mode=TYPE_QUAD, + no_swreset=True, initsequence=( (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), (0xC1, 0x33), @@ -22,6 +27,7 @@ AXS15231.extend( height=480, cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, data_rate="40MHz", + requires={"psram"}, ) DriverChip( @@ -36,6 +42,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x08), (0xF2, 0x08), @@ -267,6 +274,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x28), (0xF2, 0x28), @@ -495,6 +503,7 @@ DriverChip( color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="20MHz", + requires={"psram"}, initsequence=( (0xFF, 0xA5), (0x41, 0x03), diff --git a/esphome/components/mipi_spi/models/lanbon.py b/esphome/components/mipi_spi/models/lanbon.py index 8cec3c8317..1188300136 100644 --- a/esphome/components/mipi_spi/models/lanbon.py +++ b/esphome/components/mipi_spi/models/lanbon.py @@ -10,4 +10,5 @@ ST7789V.extend( cs_pin=22, dc_pin=21, reset_pin=18, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/lilygo.py b/esphome/components/mipi_spi/models/lilygo.py index 46ec809029..84f44a3dae 100644 --- a/esphome/components/mipi_spi/models/lilygo.py +++ b/esphome/components/mipi_spi/models/lilygo.py @@ -15,6 +15,7 @@ ST7789V.extend( dc_pin=13, reset_pin=9, data_rate="80MHz", + requires={"psram"}, ) ST7789V.extend( @@ -42,6 +43,7 @@ ST7789V.extend( enable_pin=[9, 15], data_rate="10MHz", bus_mode=TYPE_OCTAL, + requires={"psram"}, ) ST7796.extend( @@ -55,4 +57,5 @@ ST7796.extend( dc_pin=9, backlight_pin=48, invert_colors=True, + requires={"psram"}, ) diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py new file mode 100644 index 0000000000..a54bd19d88 --- /dev/null +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -0,0 +1,73 @@ +from esphome.components.mipi import ( + DFUNCTR, + GMCTRN1, + GMCTRP1, + IDMOFF, + IFMODE, + PWCTR1, + PWCTR2, + SETEXTC, + VMCTR1, + DriverChip, +) + +from .ili import ILI9341, ST7789V + +# fmt: off +DriverChip( + "M5CORE", + width=320, + height=240, + cs_pin=14, + dc_pin=27, + reset_pin=33, + initsequence=( + (SETEXTC, 0xFF, 0x93, 0x42), + (PWCTR1, 0x12, 0x12), + (PWCTR2, 0x03), + (VMCTR1, 0xF2), + (IFMODE, 0xE0), + (0xF6, 0x01, 0x00, 0x00), + (GMCTRP1, 0x00, 0x0C, 0x11, 0x04, 0x11, 0x08, 0x37, 0x89, 0x4C, 0x06, 0x0C, 0x0A, 0x2E, 0x34, 0x0F,), + (GMCTRN1, 0x00, 0x0B, 0x11, 0x05, 0x13, 0x09, 0x33, 0x67, 0x48, 0x07, 0x0E, 0x0B, 0x2E, 0x33, 0x0F,), + (DFUNCTR, 0x08, 0x82, 0x1D, 0x04), + (IDMOFF,), + ), +) + +# 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, + cs_pin=5, + dc_pin=15, + invert_colors=True, + pixel_mode="18bit", + data_rate="40MHz", + requires={"psram"}, +) + +GC9107 = ST7789V.extend( + "GC9107", + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=1, +) + +GC9107.extend( + "M5STACK-ATOMS3R-GC9107", + data_rate="40MHz", + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, + requires={"psram"}, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 3c719b0f5e..0caae5b939 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -12,7 +12,7 @@ from esphome.components.mipi import ( PWSET, DriverChip, ) -import esphome.config_validation as cv +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y from .amoled import CO5300 from .ili import ILI9488_A, ST7789V @@ -155,7 +155,7 @@ ST7789P = DriverChip( ILI9488_A.extend( "PICO-RESTOUCH-LCD-3.5", - swap_xy=cv.UNDEFINED, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, spi_16=True, pixel_mode="16bit", mirror_x=True, @@ -175,6 +175,7 @@ CO5300.extend( offset_width=6, cs_pin=12, reset_pin=39, + requires={"psram"}, ) # Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) @@ -189,6 +190,7 @@ CO5300.extend( cs_pin=12, reset_pin=39, data_rate="40MHz", + requires={"psram"}, ) AXS15231.extend( @@ -198,6 +200,7 @@ AXS15231.extend( data_rate="80MHz", cs_pin=9, reset_pin=21, + requires={"psram"}, ) # Waveshare 1.83-v2 @@ -281,4 +284,16 @@ ST7789V.extend( offset_height=40, invert_colors=True, data_rate="40MHz", + requires={"psram"}, +) + +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.64", + width=280, + height=456, + offset_width=20, + cs_pin=9, + reset_pin=21, + enable_pin=1, + requires={"psram"}, ) diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 769390ce3a..7925b7ce44 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -38,7 +38,7 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 0x28, }; -class MitsubishiClimate : public climate_ir::ClimateIR { +class MitsubishiClimate final : public climate_ir::ClimateIR { public: MitsubishiClimate() : climate_ir::ClimateIR(MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h index cdfda0c700..ea51b6b889 100644 --- a/esphome/components/mixer/speaker/automation.h +++ b/esphome/components/mixer/speaker/automation.h @@ -6,7 +6,7 @@ #ifdef USE_ESP32 namespace esphome::mixer_speaker { -template class DuckingApplyAction : public Action, public Parented { +template class DuckingApplyAction final : public Action, public Parented { TEMPLATABLE_VALUE(uint8_t, decibel_reduction); TEMPLATABLE_VALUE(uint32_t, duration); void play(const Ts &...x) override { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f1ae919b50..00e89d1782 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -44,7 +44,7 @@ namespace esphome::mixer_speaker { class MixerSpeaker; -class SourceSpeaker : public speaker::Speaker, public Component { +class SourceSpeaker final : public speaker::Speaker, public Component { public: void dump_config() override; void setup() override; @@ -118,7 +118,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { uint32_t stopping_start_ms_{0}; }; -class MixerSpeaker : public Component { +class MixerSpeaker final : public Component { public: void dump_config() override; void setup() override; diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index 28053216e2..e3b7ae5d93 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -20,7 +20,7 @@ enum MLX90393Setting { MLX90393_LAST, }; -class MLX90393Cls : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { +class MLX90393Cls final : public PollingComponent, public i2c::I2CDevice, public MLX90393Hal { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 12081f20ac..882ee45186 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -6,7 +6,7 @@ namespace esphome::mlx90614 { -class MLX90614Component : public PollingComponent, public i2c::I2CDevice { +class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 79c580c6b7..15e715e675 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -1,6 +1,8 @@ #include "mmc5603.h" #include "esphome/core/log.h" +#include + namespace esphome::mmc5603 { static const char *const TAG = "mmc5603"; @@ -143,7 +145,7 @@ void MMC5603Component::update() { const float z = 0.00625 * (raw_z - 524288); - const float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + const float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; ESP_LOGD(TAG, "Got x=%0.02fµT y=%0.02fµT z=%0.02fµT heading=%0.01f°", x, y, z, heading); if (this->x_sensor_ != nullptr) diff --git a/esphome/components/mmc5603/mmc5603.h b/esphome/components/mmc5603/mmc5603.h index 0d8eb152a7..d291e6d272 100644 --- a/esphome/components/mmc5603/mmc5603.h +++ b/esphome/components/mmc5603/mmc5603.h @@ -12,7 +12,7 @@ enum MMC5603Datarate { MMC5603_DATARATE_255_0_HZ, }; -class MMC5603Component : public PollingComponent, public i2c::I2CDevice { +class MMC5603Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mmc5983/mmc5983.h b/esphome/components/mmc5983/mmc5983.h index 020d3b2e4c..3ab9e86dcd 100644 --- a/esphome/components/mmc5983/mmc5983.h +++ b/esphome/components/mmc5983/mmc5983.h @@ -6,7 +6,7 @@ namespace esphome::mmc5983 { -class MMC5983Component : public PollingComponent, public i2c::I2CDevice { +class MMC5983Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void setup() override; diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index f6e0f98857..9e64540382 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from typing import Literal from esphome import pins @@ -10,11 +11,17 @@ from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +_LOGGER = logging.getLogger(__name__) + 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 +29,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 +76,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 +119,22 @@ 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_address(config[CONF_ADDRESS])) cg.add(parent.register_device(var)) + + +async def register_modbus_device(var, config): + # Remove before 2026.12.0 + _LOGGER.warning( + "'register_modbus_device' is deprecated, use 'register_modbus_client_device' " + "instead. Will be removed in 2026.12.0" + ) + return await register_modbus_client_device(var, config) diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index 6f97f1e605..9d7dc71547 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -29,7 +29,7 @@ MODBUS_WRITE_REGISTER_TYPE = { MODBUS_REGISTER_TYPE = { **MODBUS_WRITE_REGISTER_TYPE, "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.READ, + "read": ModbusRegisterType.INPUT_REGISTER, } SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 679ec34c0f..ecb2e4461c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -37,9 +37,35 @@ 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.data()[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_); + this->notify_no_response_(wfr); + 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,380 +73,629 @@ 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); + + 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()) + ModbusDeviceCommand &wfr = this->waiting_for_response_.value(); + uint8_t expected_address = wfr.frame.data.data()[0]; + uint8_t expected_function_code = wfr.frame.data.data()[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 device; the entry survives as an interrupted shell so the late response is ignored. + // A retry requested here stays queued behind the shell until the send-wait timeout clears it. + this->notify_no_response_(wfr); + wfr.interrupted = true; + return; + } + + 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 + + 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) { + if (this->find_device_(address) != nullptr) { + 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; +} + +ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { + for (auto *device : this->devices_) { + if (device->get_address() == address) { + return device; + } + } + return nullptr; +} + +bool ModbusServerHub::check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers) { + if ((uint32_t) start_address + number_of_registers > 0x10000u) { + ESP_LOGW(TAG, "Register address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, + number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + return false; + } + return true; +} + +void ModbusServerHub::process_modbus_client_frame_(uint8_t address, uint8_t function_code, const uint8_t *data) { + ModbusServerDevice *device = this->find_device_(address); + if (device == nullptr) { + this->expecting_peer_response_ = address; + ESP_LOGV(TAG, "Request to peer %" PRIu8 " received", address); return; + } - const ModbusDeviceCommand &frame = this->tx_buffer_.front(); + ResponseStatus status; + uint8_t response_buffer[modbus::MAX_RAW_SIZE]; + const uint8_t *response_data = response_buffer; + uint16_t response_len = 0; - if (this->role == ModbusRole::CLIENT) { - this->waiting_for_response_ = frame.data.get()[0]; + switch (static_cast(function_code)) { + case ModbusFunctionCode::READ_HOLDING_REGISTERS: + case ModbusFunctionCode::READ_INPUT_REGISTERS: { + // PDU data: start address(2) + quantity(2). + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = helpers::get_data(data, 2); + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_READ) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16, number_of_registers); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + RegisterValues registers; + if (static_cast(function_code) == ModbusFunctionCode::READ_HOLDING_REGISTERS) { + status = device->on_read_holding_registers(start_address, number_of_registers, registers); + } else { + status = device->on_read_input_registers(start_address, number_of_registers, registers); + } + + // A handler that returns an exception leaves registers partially filled, so check the exception + // first and forward it before validating the register count on the success path. + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + return; + } + + if (registers.size() != number_of_registers) { + ESP_LOGE(TAG, "Incorrect response %" PRIu16 " requested, %zu returned", number_of_registers, registers.size()); + this->send_exception_(address, function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + return; + } + + response_buffer[response_len++] = static_cast(number_of_registers * 2); // actual byte count + for (auto r : registers) { + auto register_bytes = decode_value(r); + response_buffer[response_len++] = register_bytes[0]; + response_buffer[response_len++] = register_bytes[1]; + } + break; + } + case ModbusFunctionCode::WRITE_SINGLE_REGISTER: + case ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS: { + // PDU data: start address(2) [+ quantity(2) + byte count(1)] + register values. + // A single-register write always targets one register; for a multiple-register write the + // quantity is in the frame and its byte count must equal quantity * 2. The register values are + // assembled into registers below so the handler doesn't have to know the request framing. + uint16_t start_address = helpers::get_data(data, 0); + uint16_t number_of_registers = 1; + uint16_t values_offset = 2; // single write: values follow the 2-byte start address + if (static_cast(function_code) == ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS) { + number_of_registers = helpers::get_data(data, 2); + uint8_t number_of_bytes = helpers::get_data(data, 4); + values_offset = 5; // multiple write: values follow start address(2) + quantity(2) + byte count(1) + if (number_of_registers == 0 || number_of_registers > MAX_NUM_OF_REGISTERS_TO_WRITE || + number_of_registers * 2 != number_of_bytes) { + ESP_LOGW(TAG, "Invalid number of registers %" PRIu16 " or bytes %" PRIu8, number_of_registers, + number_of_bytes); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); + return; + } + if (!this->check_register_range_(address, function_code, start_address, number_of_registers)) { + return; + } + } + // Assemble the register values (host byte order) so the handler never sees wire framing. + RegisterValues registers; + for (uint16_t i = 0; i < number_of_registers; i++) { + registers.push_back(helpers::get_data(data, values_offset + i * 2)); + } + status = device->on_write_registers(start_address, registers); + response_data = data; // echo the request header per Modbus 6.6, 6.12 + response_len = 4; + break; + } + default: + ESP_LOGW(TAG, "Unsupported function code %" PRIu8, function_code); + this->send_exception_(address, function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); + return; + } + if (status.has_value()) { + this->send_exception_(address, function_code, status.value()); + } else { + this->send_response_(address, function_code, response_data, response_len); + } +} + +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) { this->flow_control_pin_->digital_write(true); - this->write_array(frame.data.get(), frame.size); + this->write_array(frame.data.data(), frame.size()); this->flush(); this->flow_control_pin_->digital_write(false); this->last_send_tx_offset_ = 0; } else { - this->write_array(frame.data.get(), frame.size); - this->last_send_tx_offset_ = frame.size * MODBUS_BITS_PER_CHAR * MS_PER_SEC / this->parent_->get_baud_rate() + 1; + this->write_array(frame.data.data(), frame.size()); + 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.data(), 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_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, + uint16_t payload_len) { + // Build the raw frame (address + function code + payload) in a stack buffer; it's consumed + // immediately by send_raw_ and a full raw frame never exceeds MAX_RAW_SIZE. + if (payload_len + 2 > MAX_RAW_SIZE) { + ESP_LOGE(TAG, "Server response too large (%" PRIu16 " bytes)", static_cast(payload_len + 2)); 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, payload_len); + this->send_raw_(raw_frame, payload_len + 2); } -// 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()) { - 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()); +void ModbusServerHub::send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code) { + uint8_t raw_frame[3]; + raw_frame[0] = address; + raw_frame[1] = function_code | FUNCTION_CODE_EXCEPTION_MASK; + raw_frame[2] = static_cast(exception_code); + this->send_raw_(raw_frame, 3); } -// 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) { +// Raw send for client: pushes to tx queue. Everything except the CRC must be contained in payload. +void ModbusClientHub::notify_no_response_(ModbusDeviceCommand &wfr) { + if (wfr.device == nullptr) + return; + const bool retry = wfr.device->on_modbus_no_response(); + // The callback may have detached the device (e.g. clear_tx_queue_for_device()); honor the detach + // over the retry request rather than re-queueing a frame that can no longer be routed. + if (retry && wfr.device != nullptr) + this->requeue_waiting_frame_(wfr); + // The old transaction is over either way; never deliver anything else to the device through it. + wfr.device = nullptr; +} + +void ModbusClientHub::requeue_waiting_frame_(ModbusDeviceCommand &wfr) { + const ModbusFrame &frame = wfr.frame; + if (this->tx_buffer_.size() >= MODBUS_TX_BUFFER_SIZE) { + ESP_LOGE(TAG, "Write buffer full, dropped retry for address %" PRIu8, frame.data.data()[0]); + if (wfr.device != nullptr) + wfr.device->on_modbus_not_sent(); + return; + } + // Re-queue a copy (not a move): the waiting entry may have to survive as an interrupted shell. + this->tx_buffer_.emplace_back(wfr.device, frame.data.data()[0], frame.data.data() + 1, frame.size() - 3); +} + +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; + } + 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.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.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..eeba00f6b1 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -4,34 +4,41 @@ #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, -}; +// Typical frames -- reads and single-register/coil writes -- are exactly 8 bytes +// (address + 5-byte PDU + 2-byte CRC) and fit inline with no heap allocation. +static constexpr uint16_t MODBUS_FRAME_INLINE_SIZE = 8; -class ModbusDevice; +struct ModbusFrame { + // Frame held in a small-buffer-optimized buffer. Typical frames fit inline; only larger + // multi-register or custom frames spill to a single heap allocation. This keeps the common, + // high-frequency tx traffic off the heap entirely, avoiding per-frame alloc/free churn. + // The buffer tracks its own length, so no separate size field is needed. + SmallInlineBuffer data; // Modbus RTU max is 256 bytes -struct ModbusDeviceCommand { - // 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) { + uint8_t *buf = this->data.init(pdu_len + 3); + buf[0] = address; + memcpy(buf + 1, pdu, pdu_len); + auto crc = crc16(buf, pdu_len + 1); + buf[pdu_len + 1] = crc >> 0; + buf[pdu_len + 2] = crc >> 8; } + + uint16_t size() const { return static_cast(this->data.size()); } }; class Modbus : public uart::UARTDevice, public Component { @@ -39,86 +46,213 @@ 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, std::span 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_(); + // Notify the waiting device of no response; re-queues the frame if on_modbus_no_response() returns true. + // wfr is the caller's checked reference to waiting_for_response_. + void notify_no_response_(ModbusDeviceCommand &wfr); + void requeue_waiting_frame_(ModbusDeviceCommand &wfr); + 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 register_device(ModbusServerDevice *device) { this->devices_.push_back(device); } protected: - friend Modbus; + 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); + ModbusServerDevice *find_device_(uint8_t address); + // Returns true if [start_address, start_address + number_of_registers) fits in the 16-bit address space. + // On failure, logs and sends an ILLEGAL_DATA_ADDRESS exception to the client. + bool check_register_range_(uint8_t address, uint8_t function_code, uint16_t start_address, + uint16_t number_of_registers); + void send_raw_(const uint8_t *payload, uint16_t len); + void send_exception_(uint8_t address, uint8_t function_code, ModbusExceptionCode exception_code); + void send_response_(uint8_t address, uint8_t function_code, const uint8_t *payload, uint16_t payload_len); + uint8_t expecting_peer_response_{0}; + std::vector devices_; - Modbus *parent_; - uint8_t address_; + // 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() {} + /// Called when no (valid) response arrived; return true to have the hub re-queue the frame for a retry. + /// The hub does not bound retries: the device is responsible for limiting them (e.g. track a counter and + /// return false when exhausted), or an unresponsive peer will starve other traffic on the bus. + virtual bool on_modbus_no_response() { return false; } + 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(std::span 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 +// Remove before 2026.12.0 +using ModbusDevice ESPDEPRECATED("Use ModbusClientDevice instead. Removed in 2026.12.0", + "2026.6.0") = ModbusClientDevice; + +// Transaction status: std::nullopt on success, otherwise the Modbus exception code. Server handlers return it; +// (future) client response callbacks receive it. Named without a side prefix so both directions share it. +using ResponseStatus = std::optional; +// Register values exchanged with server handlers, in host byte order. Sized at the larger of the two protocol +// maxima (read = 125 / 0x7D, write = 123 / 0x7B); the per-direction count limit is enforced by the hub, not by +// the capacity of this type. +using RegisterValues = StaticVector; + +class ModbusServerDevice { + public: + virtual ~ModbusServerDevice() = default; + ModbusServerDevice() = default; + // Polymorphic base: non-copyable and non-movable to prevent slicing (Rule of Five). + ModbusServerDevice(const ModbusServerDevice &) = delete; + ModbusServerDevice &operator=(const ModbusServerDevice &) = delete; + ModbusServerDevice(ModbusServerDevice &&) = delete; + ModbusServerDevice &operator=(ModbusServerDevice &&) = delete; + void set_address(uint8_t address) { this->address_ = address; } + uint8_t get_address() const { return this->address_; } + virtual ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; + virtual ResponseStatus on_read_input_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); + }; + virtual ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + RegisterValues ®isters) { + return this->on_read_registers(start_address, number_of_registers, registers); + }; + virtual ResponseStatus on_write_registers(uint16_t start_address, const RegisterValues ®isters) { + return ModbusExceptionCode::ILLEGAL_FUNCTION; + }; + + protected: + 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..d11748bcd9 100644 --- a/esphome/components/modbus/modbus_definitions.h +++ b/esphome/components/modbus/modbus_definitions.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome::modbus { @@ -14,7 +15,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 +37,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 { @@ -55,7 +49,11 @@ enum class ModbusRegisterType : uint8_t { COIL = 0x01, DISCRETE_INPUT = 0x02, HOLDING = 0x03, - READ = 0x04, + // Named INPUT_REGISTER (not INPUT) because Arduino cores define INPUT as a macro. + INPUT_REGISTER = 0x04, + // Remove before 2027.2.0 + READ ESPDEPRECATED("Use ModbusRegisterType::INPUT_REGISTER instead. Removed in 2027.2.0", "2026.7.0") = + INPUT_REGISTER, }; // 7 MODBUS Exception Responses: @@ -75,12 +73,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 uint16_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 256 - 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..de109606cb 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: @@ -28,54 +101,20 @@ static size_t required_payload_size(SensorValueType sensor_value_type) { } } -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - switch (value_type) { - case SensorValueType::U_WORD: - case SensorValueType::S_WORD: - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD: - case SensorValueType::S_DWORD: - case SensorValueType::FP32: - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::S_DWORD_R: - case SensorValueType::FP32_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - data.push_back((value & 0xFFFF000000000000) >> 48); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF000000000000) >> 48); - break; - default: - ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); - break; - } +void log_unsupported_value_type(SensorValueType value_type) { + ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); } -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { 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 // a malformed or misconfigured frame still produces an error log. - if (static_cast(offset) > data.size()) { + if (static_cast(offset) > 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()); - return value; + static_cast(offset), size); + return std::nullopt; } const size_t required_size = required_payload_size(sensor_value_type); @@ -83,11 +122,10 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens return value; } - if (data.size() - offset < required_size) { + if (size - offset < required_size) { 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); - return value; + static_cast(sensor_value_type), static_cast(offset), size, required_size); + return std::nullopt; } switch (sensor_value_type) { @@ -136,4 +174,124 @@ int64_t payload_to_number(const std::vector &data, SensorValueType sens } return value; } + +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) { + const size_t required_size = required_payload_size(sensor_value_type); + if (required_size == 0) { + return 0; // RAW/unsupported: nothing to read + } + const size_t required_words = required_size / 2; + if (required_words > count) { + ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", + static_cast(sensor_value_type), count, required_words); + return std::nullopt; + } + // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the + // sign-extension behaviour stays identical to the wire path. + uint8_t bytes[8]; // at most 4 registers (QWORD) + for (size_t i = 0; i < required_words; i++) { + uint16_t reg = registers[i]; + bytes[i * 2] = static_cast(reg >> 8); + bytes[i * 2 + 1] = static_cast(reg & 0xFF); + } + return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); +} + +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..45a13f7582 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -1,14 +1,68 @@ #pragma once +#include +#include +#include #include #include -#include #include "esphome/core/helpers.h" #include "esphome/components/modbus/modbus_definitions.h" 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 @@ -38,24 +92,24 @@ inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_t return ModbusFunctionCode::READ_DISCRETE_INPUTS; case ModbusRegisterType::HOLDING: return ModbusFunctionCode::READ_HOLDING_REGISTERS; - case ModbusRegisterType::READ: + case ModbusRegisterType::INPUT_REGISTER: 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; - case ModbusRegisterType::READ: + return multiple ? ModbusFunctionCode::WRITE_MULTIPLE_REGISTERS : ModbusFunctionCode::WRITE_SINGLE_REGISTER; + // These register types can't be written (per spec) + case ModbusRegisterType::INPUT_REGISTER: + case ModbusRegisterType::DISCRETE_INPUT: default: - return ModbusFunctionCode::CUSTOM; + return ModbusFunctionCode::INVALID; } } @@ -112,31 +166,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 @@ -145,11 +199,15 @@ template T get_data(const std::vector &data, size_t buffer_ * @param data modbus response buffer (uint8_t) * @return content of coil register */ -inline bool coil_from_vector(int coil, const std::vector &data) { - auto data_byte = coil / 8; - return (data[data_byte] & (1 << (coil % 8))) > 0; +inline bool bit_from_packed(int bit, std::span data) { + auto data_byte = bit / 8; + return (data[data_byte] & (1 << (bit % 8))) > 0; } +// Remove before 2027.2.0 +ESPDEPRECATED("Use bit_from_packed() instead. Removed in 2027.2.0", "2026.8.0") +inline bool coil_from_vector(int coil, std::span data) { return bit_from_packed(coil, data); } + /** Extract bits from value and shift right according to the bitmask * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. * the result is then shifted right by the position if the first right set bit in the mask @@ -172,23 +230,103 @@ template N mask_and_shift_by_rightbit(N data, uint32_t mask) { return 0; } -/** Convert float value to vector suitable for sending - * @param data target for payload - * @param value float value to convert - * @param value_type defines if 16/32 or FP32 is used - * @return vector containing the modbus register words in correct order - */ -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); +// Logs an error for an unsupported value type. Defined in the .cpp so logging stays out of headers. +void log_unsupported_value_type(SensorValueType value_type); -/** Convert vector response payload to number. +/** Append the Modbus register words for value to data. + * Works with any container exposing push_back(uint16_t) (e.g. std::vector or StaticVector). + */ +template void number_to_payload(Container &data, int64_t value, SensorValueType value_type) { + switch (value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::FP32: + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + data.push_back((value & 0xFFFF000000000000) >> 48); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF000000000000) >> 48); + break; + default: + log_unsupported_value_type(value_type); + break; + } +} + +/** Convert a raw response payload to a number. * @param data payload with the data to convert + * @param size number of bytes available in data * @param sensor_value_type defines if 16/32/64 bits or FP32 is used * @param offset offset to the data in data * @param bitmask bitmask used for masking and shifting * @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); +std::optional payload_to_number(const uint8_t *data, size_t size, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask); + +/** Convert a response payload span to number; std::nullopt if the payload is too short. */ +inline std::optional payload_to_number(std::span data, SensorValueType sensor_value_type, + uint8_t offset, uint32_t bitmask) { + return payload_to_number(data.data(), data.size(), sensor_value_type, offset, bitmask); +} + +// Remove before 2027.2.0 +ESPDEPRECATED("Use the std::span overload returning std::optional instead. Removed in 2027.2.0", "2026.8.0") +inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask) { + // Released behavior: a too-short payload logs an error and decodes to 0. + return payload_to_number(std::span(data), sensor_value_type, offset, bitmask).value_or(0); +} + +/** Reconstruct a number from register words (host byte order). Inverse of number_to_payload. + * Decodes the value at the start of the given span; advance the pointer to read successive values. + * @param registers register values in host byte order + * @param count number of registers available in registers + * @param sensor_value_type defines if 16/32/64 bits or FP32 is used + * @return 64-bit number of the registers + */ +std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); + +/** 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/__init__.py b/esphome/components/modbus_controller/__init__.py index 67e5757397..527e9b047f 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -11,6 +11,7 @@ from esphome.components.modbus.helpers import ( import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging +from esphome.types import ConfigType from .const import ( CONF_ALLOW_DUPLICATE_COMMANDS, @@ -42,7 +43,7 @@ MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") ModbusController = modbus_controller_ns.class_( - "ModbusController", cg.PollingComponent, modbus.ModbusDevice + "ModbusController", cg.PollingComponent, modbus.ModbusClientDevice ) SensorItem = modbus_controller_ns.struct("SensorItem") @@ -117,7 +118,7 @@ def validate_modbus_register(config): return config -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_controller", role="client")( config ) @@ -211,7 +212,7 @@ async def to_code(config): async def register_modbus_device(var, config): 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_client_device(var, config) def function_code_to_register(function_code): @@ -219,7 +220,7 @@ def function_code_to_register(function_code): "read_coils": ModbusRegisterType.COIL, "read_discrete_inputs": ModbusRegisterType.DISCRETE_INPUT, "read_holding_registers": ModbusRegisterType.HOLDING, - "read_input_registers": ModbusRegisterType.READ, + "read_input_registers": ModbusRegisterType.INPUT_REGISTER, "write_single_coil": ModbusRegisterType.COIL, "write_single_register": ModbusRegisterType.HOLDING, "write_multiple_coils": ModbusRegisterType.COIL, diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index 60c19bb66a..9656013a5f 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -14,7 +14,7 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h index 98c6840e15..3f7c6b4dd6 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusBinarySensor : public Component, public binary_sensor::BinarySensor, public SensorItem { +class ModbusBinarySensor final : public Component, public binary_sensor::BinarySensor, public SensorItem { public: ModbusBinarySensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { 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..484b59ede3 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -64,9 +64,10 @@ T get_data(const std::vector &data, size_t buffer_offset) { return modbus::helpers::get_data(data, buffer_offset); } -ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0") +// Remove before 2027.2.0 (window restarted when the migration target changed to bit_from_packed()) +ESPDEPRECATED("Use modbus::helpers::bit_from_packed() instead. Removed in 2027.2.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { - return modbus::helpers::coil_from_vector(coil, data); + return modbus::helpers::bit_from_packed(coil, data); } template @@ -83,7 +84,8 @@ inline void number_to_payload(std::vector &data, int64_t value, Sensor ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, uint32_t bitmask) { - return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask); + return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) + .value_or(0); } ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") @@ -279,7 +281,7 @@ class ModbusCommandItem { * Responses for the commands are dispatched to the modbus sensor items. */ -class ModbusController : public PollingComponent, public modbus::ModbusDevice { +class ModbusController final : public PollingComponent, public modbus::ModbusClientDevice { public: void dump_config() override; void loop() override; @@ -377,8 +379,9 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { * @param item SensorItem object * @return float value of data */ -inline float payload_to_float(const std::vector &data, const SensorItem &item) { - int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); +inline float payload_to_float(std::span data, const SensorItem &item) { + int64_t number = + modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask).value_or(0); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index dd8f418bfc..ce64099170 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { using value_to_data_t = std::function(float); -class ModbusNumber : public number::Number, public Component, public SensorItem { +class ModbusNumber final : public number::Number, public Component, public SensorItem { public: ModbusNumber(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index c5323e3bf3..d904e58bd7 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusFloatOutput : public output::FloatOutput, public Component, public SensorItem { +class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { this->register_type = ModbusRegisterType::HOLDING; @@ -41,7 +41,7 @@ class ModbusFloatOutput : public output::FloatOutput, public Component, public S bool use_write_multiple_{false}; }; -class ModbusBinaryOutput : public output::BinaryOutput, public Component, public SensorItem { +class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = ModbusRegisterType::COIL; diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 859828f5f6..c650ca7641 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -8,7 +8,9 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); + int64_t value = modbus::helpers::payload_to_number(std::span(data), this->sensor_value_type, + this->offset, this->bitmask) + .value_or(0); ESP_LOGD(TAG, "New select value %lld from payload", value); diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index a736abd0db..fb9283305c 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -9,7 +9,7 @@ namespace esphome::modbus_controller { -class ModbusSelect : public Component, public select::Select, public SensorItem { +class ModbusSelect final : public Component, public select::Select, public SensorItem { public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, uint16_t skip_updates, bool force_new_range, std::vector mapping) { diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.h b/esphome/components/modbus_controller/sensor/modbus_sensor.h index 2e6967b07c..ea4f560b9c 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.h +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSensor : public Component, public sensor::Sensor, public SensorItem { +class ModbusSensor final : public Component, public sensor::Sensor, public SensorItem { public: ModbusSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 044ca2f8cc..c8b3868bdc 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -33,7 +33,7 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = modbus::helpers::coil_from_vector(this->offset, data); + value = modbus::helpers::bit_from_packed(this->offset, data); break; default: value = modbus::helpers::get_data(data, this->offset) & this->bitmask; diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 541a23706d..d6e991582d 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSwitch : public Component, public switch_::Switch, public SensorItem { +class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { public: ModbusSwitch(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h index a99fea5860..e9130c98d4 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { enum class RawEncoding { NONE = 0, HEXBYTES = 1, COMMA = 2, ANSI = 3 }; -class ModbusTextSensor : public Component, public text_sensor::TextSensor, public SensorItem { +class ModbusTextSensor final : public Component, public text_sensor::TextSensor, public SensorItem { public: ModbusTextSensor(ModbusRegisterType register_type, uint16_t start_address, uint8_t offset, uint8_t register_count, uint16_t response_bytes, RawEncoding encode, uint16_t skip_updates, bool force_new_range) { diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 5182bc05d1..14f4ca8a4d 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -8,8 +8,10 @@ from esphome.components.modbus.helpers import ( ) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType from .const import ( + CONF_ALLOW_PARTIAL_READ, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -27,7 +29,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") @@ -41,17 +43,62 @@ SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( } ) +# RAW has no numeric encoding, so it is not a valid server register type: a server value is produced by a +# lambda and encoded into registers, and on the server a RAW register would just be a single 16-bit word -- +# use U_WORD for that. Restrict the choices to the encodable types. +SERVER_SENSOR_VALUE_TYPE = { + key: value for key, value in SENSOR_VALUE_TYPE.items() if key != "RAW" +} + ModbusServerRegisterSchema = cv.Schema( { cv.GenerateID(): cv.declare_id(ServerRegister), - cv.Required(CONF_ADDRESS): cv.positive_int, - cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(SENSOR_VALUE_TYPE), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum( + SERVER_SENSOR_VALUE_TYPE + ), cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_ALLOW_PARTIAL_READ, default=False): cv.boolean, } ) +def _validate_register_ranges(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit + # Modbus address space (0x0000-0xFFFF). + for register in config.get(CONF_REGISTERS, []): + address = register[CONF_ADDRESS] + register_count = TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]] + if address + register_count > 0x10000: + raise cv.Invalid( + f"Register at 0x{address:04X} spans {register_count} register(s) and runs past " + "the end of the 16-bit address space (0xFFFF)", + path=[CONF_REGISTERS], + ) + return config + + +def _validate_no_overlapping_registers(config: ConfigType) -> ConfigType: + # Each register occupies [address, address + register_count). Reject configs where any two ranges + # overlap -- the same address twice, or a multi-register value straddling a neighbour -- since the + # server resolves a request by the value containing an address and overlaps are ambiguous. + spans = sorted( + (register[CONF_ADDRESS], TYPE_REGISTER_MAP[register[CONF_VALUE_TYPE]]) + for register in config.get(CONF_REGISTERS, []) + ) + for (address, register_count), (next_address, _) in zip( + spans, spans[1:], strict=False + ): + if next_address < address + register_count: + raise cv.Invalid( + f"Register address 0x{next_address:04X} overlaps the register at 0x{address:04X}, " + f"which spans {register_count} register(s); each register's address range must be unique", + path=[CONF_REGISTERS], + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -61,11 +108,13 @@ CONFIG_SCHEMA = cv.All( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), } - ).extend(modbus.modbus_device_schema(0x01)), + ).extend(modbus.modbus_device_schema(0x01, role="server")), + _validate_register_ranges, + _validate_no_overlapping_registers, ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> ConfigType: return modbus.final_validate_modbus_device("modbus_server", role="server")(config) @@ -118,7 +167,8 @@ async def to_code(config): ), ) ) + if server_register[CONF_ALLOW_PARTIAL_READ]: + cg.add(server_register_var.set_allow_partial_read(True)) 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/const.py b/esphome/components/modbus_server/const.py index f83211c207..f2a8c53f45 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,3 +5,4 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e5ea2efa4d..4c4e72a086 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -3,136 +3,120 @@ #include "esphome/core/log.h" namespace esphome::modbus_server { -using modbus::ModbusFunctionCode; using modbus::ModbusExceptionCode; +using modbus::helpers::registers_to_number; static const char *const TAG = "modbus_server"; -void ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address, - uint16_t number_of_registers) { - ESP_LOGV(TAG, - "Received read holding/input registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); +// The widest Modbus value type (QWORD) spans four registers. +static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4; +// number_to_payload() encodes the 64-bit value returned by read_lambda() into 16-bit registers, so the +// widest possible value spans exactly sizeof(int64_t) / sizeof(uint16_t) registers. Tie the bound to that +// source so a future wider value type -- which would require widening the encoded value itself -- can't +// silently overflow the value_words buffer below (StaticVector::push_back drops words past capacity). +static_assert(MAX_REGISTERS_PER_VALUE == sizeof(int64_t) / sizeof(uint16_t), + "MAX_REGISTERS_PER_VALUE must match the register span of the widest encodable value"); - 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); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } - - std::vector sixteen_bit_response; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { - bool found = false; - for (auto *server_register : this->server_registers_) { - if (server_register->address == current_address) { - if (!server_register->read_lambda) { - break; - } - int64_t value = server_register->read_lambda(); - 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()); - - std::vector payload; - payload.reserve(server_register->register_count * 2); - modbus::helpers::number_to_payload(payload, value, server_register->value_type); - sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); - current_address += server_register->register_count; - found = true; - break; - } - } - - if (!found) { - if (this->server_courtesy_response_.enabled && - (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.", - 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 - } else { - ESP_LOGW(TAG, - "Could not match any register to address 0x%02X and default not allowed. Sending exception response.", - current_address); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); - return; - } +ServerRegister *ModbusServer::find_containing_register_(uint32_t address) const { + for (auto *server_register : this->server_registers_) { + if (address >= server_register->address && + address < static_cast(server_register->address) + server_register->register_count) { + return server_register; } } - - std::vector response; - 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()); + return nullptr; } -void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::vector &data) { - uint16_t number_of_registers; - uint16_t payload_offset; +modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + ESP_LOGV(TAG, + "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", + this->address_, start_address, number_of_registers); - if (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); - return; + const uint32_t end_address = static_cast(start_address) + number_of_registers; + uint32_t current_address = start_address; + while (current_address < end_address) { + ServerRegister *server_register = this->find_containing_register_(current_address); + + if (server_register == nullptr) { + // Unregistered address: optionally answer with the courtesy default, otherwise reject. + if (this->server_courtesy_response_.enabled && + current_address <= this->server_courtesy_response_.register_last_address) { + ESP_LOGV(TAG, "No register at 0x%04X; returning courtesy default %" PRIu16 ".", + static_cast(current_address), this->server_courtesy_response_.register_value); + registers.push_back(this->server_courtesy_response_.register_value); + current_address += 1; // the courtesy default is always a single register + continue; + } + ESP_LOGW(TAG, "No register at 0x%04X and courtesy default not allowed. Sending exception response.", + static_cast(current_address)); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } - 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); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; + + if (!server_register->read_lambda) { + // Registered but not readable (write-only); don't mask it with the courtesy default. + ESP_LOGW(TAG, "Register at 0x%04X is not readable. Sending exception response.", server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } - 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.", - payload_size, number_of_registers); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; + + // A multi-register value is normally atomic: the request must start at its first register and cover all of + // it. A value may opt in to partial reads, in which case the request may start inside it or stop short of + // its end and we return only the covered words. + const uint16_t value_offset = static_cast(current_address - server_register->address); + const uint16_t words_available = static_cast(server_register->register_count - value_offset); + const uint16_t words_wanted = static_cast(end_address - current_address); + const uint16_t take = words_available < words_wanted ? words_available : words_wanted; + const bool clipped = value_offset != 0 || take != server_register->register_count; + if (clipped && !server_register->allow_partial_read) { + ESP_LOGW(TAG, + "Read clips the multi-register value at 0x%04X, which does not allow partial reads. " + "Sending exception response.", + server_register->address); + return ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; } - if (data.size() < 5 + payload_size) { - ESP_LOGW(TAG, "Write multiple registers payload truncated (%zu bytes, expected %u)", data.size(), - 5 + payload_size); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_DATA_VALUE); - return; + + 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, value_buf, sizeof(value_buf))); + + // Encode the whole value once (wire word order) and emit only the covered words. Slicing the encoded words + // handles the reversed value types for free, since number_to_payload already emits in wire order. + StaticVector value_words; + modbus::helpers::number_to_payload(value_words, value, server_register->value_type); + if (value_offset + take > value_words.size()) { + // The value encoded to fewer words than its register span (e.g. a RAW register); treat as a device fault. + ESP_LOGE(TAG, "Register at 0x%04X did not encode to %u registers", server_register->address, + server_register->register_count); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; } - payload_offset = 5; - } else if (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); - return; + for (uint16_t i = 0; i < take; i++) { + registers.push_back(value_words[value_offset + i]); } - number_of_registers = 1; - payload_offset = 2; - } else { - ESP_LOGW(TAG, "Invalid function code 0x%X. Sending exception response.", function_code); - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; + current_address += take; } - uint16_t start_address = uint16_t(data[1]) | (uint16_t(data[0]) << 8); - ESP_LOGD(TAG, - "Received write holding registers for device 0x%X. FC: 0x%X. Start address: 0x%X. Number of registers: " - "0x%X.", - this->address_, function_code, start_address, number_of_registers); + return {}; +} - auto for_each_register = [this, start_address, number_of_registers, payload_offset]( - const std::function &callback) -> bool { - uint16_t offset = payload_offset; - for (uint16_t current_address = start_address; current_address < start_address + number_of_registers;) { +modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + // registers holds the values to write in host byte order; its size is the register count. + ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", + this->address_, start_address, registers.size()); + + auto for_each_register = + [this, start_address, + ®isters](const std::function &callback) -> bool { + uint16_t register_offset = 0; + for (uint32_t current_address = start_address; current_address < start_address + registers.size();) { bool ok = false; for (auto *server_register : this->server_registers_) { if (server_register->address == current_address) { - ok = callback(server_register, offset); + ok = callback(server_register, register_offset); current_address += server_register->register_count; - offset += server_register->register_count * sizeof(uint16_t); + register_offset += server_register->register_count; break; } } @@ -144,29 +128,41 @@ void ModbusServer::on_modbus_write_registers(uint8_t function_code, const std::v return true; }; - // check all registers are writable before writing to any of them: - if (!for_each_register([](ServerRegister *server_register, uint16_t offset) -> bool { - return server_register->write_lambda != nullptr; + // Pre-flight: every targeted register must be writable AND have its full value present in the request, + // so we never apply a partial write before discovering a problem. The commit pass below re-runs + // registers_to_number rather than caching the decoded values: using the same function for the check and + // the write keeps a single source of truth for the decode bound, independent of how register_count was set. + ModbusExceptionCode precheck = ModbusExceptionCode::ILLEGAL_DATA_ADDRESS; // unmatched or unwritable register + if (!for_each_register([&precheck, ®isters](ServerRegister *server_register, uint16_t register_offset) -> bool { + if (server_register->write_lambda == nullptr) { + return false; // unwritable -> ILLEGAL_DATA_ADDRESS + } + if (!registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type) + .has_value()) { + precheck = ModbusExceptionCode::ILLEGAL_DATA_VALUE; // request doesn't supply the full value + return false; + } + return true; })) { - this->send_error(function_code, ModbusExceptionCode::ILLEGAL_FUNCTION); - return; + ESP_LOGW(TAG, "Write request rejected before applying any register. Sending exception response."); + return precheck; } - // 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); + // Commit: every value is known writable and decodable, so the only failure now is a user write callback + // rejecting the value at runtime -- which cannot be rolled back. + if (!for_each_register([®isters](ServerRegister *server_register, uint16_t register_offset) { + int64_t number = registers_to_number(registers.data() + register_offset, registers.size() - register_offset, + server_register->value_type) + .value_or(0); return server_register->write_lambda(number); })) { - this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); - return; + ESP_LOGW(TAG, "A register write callback failed mid-sequence; earlier writes were already applied."); + return ModbusExceptionCode::SERVICE_DEVICE_FAILURE; } - std::vector response; - response.reserve(6); - response.push_back(this->address_); - response.push_back(function_code); - response.insert(response.end(), data.begin(), data.begin() + 4); - this->send_raw(response); + // Success: the caller builds the write response (an echo of the request header). + return {}; } void ModbusServer::dump_config() { diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index 0fc2e0bef5..4fddd9854d 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -52,55 +52,60 @@ 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; } } + void set_allow_partial_read(bool allow_partial_read) { this->allow_partial_read = allow_partial_read; } + uint16_t address{0}; SensorValueType value_type{SensorValueType::RAW}; uint8_t register_count{0}; + // When true, a read may cover only part of this multi-register value; otherwise it must read the whole value. + bool allow_partial_read{false}; ReadLambda read_lambda; WriteLambda write_lambda; }; -class ModbusServer : public Component, public modbus::ModbusDevice { +class ModbusServer final : 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 - void on_modbus_read_registers(uint8_t function_code, uint16_t start_address, uint16_t number_of_registers) final; + modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors - void on_modbus_write_registers(uint8_t function_code, const std::vector &data) final; + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; @@ -109,6 +114,8 @@ class ModbusServer : public Component, public modbus::ModbusDevice { ServerCourtesyResponse get_server_courtesy_response() const { return this->server_courtesy_response_; } protected: + /// Find the registered value whose register span contains address, or nullptr if none does. + ServerRegister *find_containing_register_(uint32_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; /// Server courtesy response diff --git a/esphome/components/monochromatic/monochromatic_light_output.h b/esphome/components/monochromatic/monochromatic_light_output.h index 458140ef09..eb81a10ee4 100644 --- a/esphome/components/monochromatic/monochromatic_light_output.h +++ b/esphome/components/monochromatic/monochromatic_light_output.h @@ -6,7 +6,7 @@ namespace esphome::monochromatic { -class MonochromaticLightOutput : public light::LightOutput { +class MonochromaticLightOutput final : public light::LightOutput { public: void set_output(output::FloatOutput *output) { output_ = output; } light::LightTraits get_traits() override { diff --git a/esphome/components/mopeka_ble/mopeka_ble.h b/esphome/components/mopeka_ble/mopeka_ble.h index cc91ef17d6..e6fae23aee 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.h +++ b/esphome/components/mopeka_ble/mopeka_ble.h @@ -9,7 +9,7 @@ namespace esphome::mopeka_ble { -class MopekaListener : public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; void set_show_sensors_without_sync(bool show_sensors_without_sync) { diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index bfdfe80c48..40fb338350 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -27,7 +27,7 @@ enum SensorType { // measurement may be inaccurate. enum SensorReadQuality { QUALITY_HIGH = 0x3, QUALITY_MED = 0x2, QUALITY_LOW = 0x1, QUALITY_ZERO = 0x0 }; -class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaProCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.h b/esphome/components/mopeka_std_check/mopeka_std_check.h index a38abeabf0..2f1681f6ea 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.h +++ b/esphome/components/mopeka_std_check/mopeka_std_check.h @@ -42,7 +42,7 @@ struct mopeka_std_package { // NOLINT(readability-identifier-naming,altera-stru mopeka_std_values val[3]; } __attribute__((packed)); -class MopekaStdCheck : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class MopekaStdCheck final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index 00310c16fe..b0a074a17c 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -85,7 +85,7 @@ class MotionComponent : public PollingComponent { // --- Actions --- -template class CalibrateLevelAction : public Action { +template class CalibrateLevelAction final : public Action { public: explicit CalibrateLevelAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -110,7 +110,7 @@ template class CalibrateLevelAction : public Action { bool save_{false}; }; -template class CalibrateHeadingAction : public Action { +template class CalibrateHeadingAction final : public Action { public: explicit CalibrateHeadingAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } @@ -135,7 +135,7 @@ template class CalibrateHeadingAction : public Action { bool save_{false}; }; -template class ClearCalibrationAction : public Action { +template class ClearCalibrationAction final : public Action { public: explicit ClearCalibrationAction(MotionComponent *parent) : parent_(parent) {} void set_save(bool save) { this->save_ = save; } diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index d7994327b1..238e37aff0 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -75,16 +75,16 @@ void MPL3115A2Component::update() { float altitude = 0, pressure = 0; if (this->altitude_ != nullptr) { int32_t alt = encode_uint32(buffer[0], buffer[1], buffer[2], 0); - altitude = float(alt) / 65536.0; + altitude = float(alt) / 65536.0f; this->altitude_->publish_state(altitude); } else { uint32_t p = encode_uint32(0, buffer[0], buffer[1], buffer[2]); - pressure = float(p) / 6400.0; + pressure = float(p) / 6400.0f; if (this->pressure_ != nullptr) this->pressure_->publish_state(pressure); } int16_t t = encode_uint16(buffer[3], buffer[4]); - float temperature = float(t) / 256.0; + float temperature = float(t) / 256.0f; if (this->temperature_ != nullptr) this->temperature_->publish_state(temperature); diff --git a/esphome/components/mpl3115a2/mpl3115a2.h b/esphome/components/mpl3115a2/mpl3115a2.h index d78c9d571c..a6163673cb 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.h +++ b/esphome/components/mpl3115a2/mpl3115a2.h @@ -80,7 +80,7 @@ enum { MPL3115A2_CTRL_REG1_OS128 = 0x38, }; -class MPL3115A2Component : public PollingComponent, public i2c::I2CDevice { +class MPL3115A2Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_altitude(sensor::Sensor *altitude) { altitude_ = altitude; } diff --git a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h index 5fa10bf598..c0a4a36f1f 100644 --- a/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h +++ b/esphome/components/mpr121/binary_sensor/mpr121_binary_sensor.h @@ -6,7 +6,9 @@ namespace esphome::mpr121 { -class MPR121BinarySensor : public binary_sensor::BinarySensor, public MPR121Channel, public Parented { +class MPR121BinarySensor final : public binary_sensor::BinarySensor, + public MPR121Channel, + public Parented { public: void set_channel(uint8_t channel) { this->channel_ = channel; } void set_touch_threshold(uint8_t touch_threshold) { this->touch_threshold_ = touch_threshold; }; diff --git a/esphome/components/mpr121/mpr121.h b/esphome/components/mpr121/mpr121.h index 54b5c8abf4..64c4b291b3 100644 --- a/esphome/components/mpr121/mpr121.h +++ b/esphome/components/mpr121/mpr121.h @@ -57,7 +57,7 @@ class MPR121Channel { virtual void process(uint16_t data) = 0; }; -class MPR121Component : public Component, public i2c::I2CDevice { +class MPR121Component final : public Component, public i2c::I2CDevice { public: void register_channel(MPR121Channel *channel) { this->channels_.push_back(channel); } void set_touch_debounce(uint8_t debounce); @@ -102,7 +102,7 @@ class MPR121Component : public Component, public i2c::I2CDevice { }; /// Helper class to expose a MPR121 pin as an internal input GPIO pin. -class MPR121GPIOPin : public GPIOPin { +class MPR121GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/mpu6050/mpu6050.h b/esphome/components/mpu6050/mpu6050.h index bac07cb4a5..4410bf0164 100644 --- a/esphome/components/mpu6050/mpu6050.h +++ b/esphome/components/mpu6050/mpu6050.h @@ -6,7 +6,7 @@ namespace esphome::mpu6050 { -class MPU6050Component : public PollingComponent, public i2c::I2CDevice { +class MPU6050Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mpu6886/mpu6886.h b/esphome/components/mpu6886/mpu6886.h index a23858a7b7..b795d5f690 100644 --- a/esphome/components/mpu6886/mpu6886.h +++ b/esphome/components/mpu6886/mpu6886.h @@ -6,7 +6,7 @@ namespace esphome::mpu6886 { -class MPU6886Component : public PollingComponent, public i2c::I2CDevice { +class MPU6886Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 86bba11a60..4a5eacf449 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -57,6 +57,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, PLATFORM_RTL87XX, PlatformFramework, ) @@ -318,7 +319,15 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), + cv.only_on( + [ + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, + ] + ), _consume_mqtt_sockets, ) diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.h b/esphome/components/mqtt/mqtt_alarm_control_panel.h index 89a0ff1be8..b2da7ed6a2 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.h +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTAlarmControlPanelComponent : public mqtt::MQTTComponent { +class MQTTAlarmControlPanelComponent final : public mqtt::MQTTComponent { public: explicit MQTTAlarmControlPanelComponent(alarm_control_panel::AlarmControlPanel *alarm_control_panel); diff --git a/esphome/components/mqtt/mqtt_binary_sensor.h b/esphome/components/mqtt/mqtt_binary_sensor.h index 5917a9966c..75c224c65a 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.h +++ b/esphome/components/mqtt/mqtt_binary_sensor.h @@ -9,7 +9,7 @@ namespace esphome::mqtt { -class MQTTBinarySensorComponent : public mqtt::MQTTComponent { +class MQTTBinarySensorComponent final : public mqtt::MQTTComponent { public: /** Construct a MQTTBinarySensorComponent. * diff --git a/esphome/components/mqtt/mqtt_button.h b/esphome/components/mqtt/mqtt_button.h index a2db64d39d..7e2c77e29b 100644 --- a/esphome/components/mqtt/mqtt_button.h +++ b/esphome/components/mqtt/mqtt_button.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTButtonComponent : public mqtt::MQTTComponent { +class MQTTButtonComponent final : public mqtt::MQTTComponent { public: explicit MQTTButtonComponent(button::Button *button); diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 14473f737a..f741be561c 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -99,7 +99,7 @@ enum MQTTClientState { class MQTTComponent; -class MQTTClientComponent : public Component { +class MQTTClientComponent final : public Component { public: MQTTClientComponent(); @@ -340,7 +340,7 @@ class MQTTClientComponent : public Component { extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger : public Trigger, public Component { +class MQTTMessageTrigger final : public Trigger, public Component { public: explicit MQTTMessageTrigger(std::string topic); @@ -356,7 +356,7 @@ class MQTTMessageTrigger : public Trigger, public Component { optional payload_; }; -class MQTTJsonMessageTrigger : public Trigger { +class MQTTJsonMessageTrigger final : public Trigger { public: explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { global_mqtt_client->subscribe_json( @@ -364,21 +364,21 @@ class MQTTJsonMessageTrigger : public Trigger { } }; -class MQTTConnectTrigger : public Trigger { +class MQTTConnectTrigger final : public Trigger { public: explicit MQTTConnectTrigger(MQTTClientComponent *client) { client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); } }; -class MQTTDisconnectTrigger : public Trigger { +class MQTTDisconnectTrigger final : public Trigger { public: explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); } }; -template class MQTTPublishAction : public Action { +template class MQTTPublishAction final : public Action { public: MQTTPublishAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -395,7 +395,7 @@ template class MQTTPublishAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTPublishJsonAction : public Action { +template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, topic) @@ -417,7 +417,7 @@ template class MQTTPublishJsonAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTConnectedCondition : public Condition { +template class MQTTConnectedCondition final : public Condition { public: MQTTConnectedCondition(MQTTClientComponent *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->is_connected(); } @@ -426,7 +426,7 @@ template class MQTTConnectedCondition : public Condition MQTTClientComponent *parent_; }; -template class MQTTEnableAction : public Action { +template class MQTTEnableAction final : public Action { public: MQTTEnableAction(MQTTClientComponent *parent) : parent_(parent) {} @@ -436,7 +436,7 @@ template class MQTTEnableAction : public Action { MQTTClientComponent *parent_; }; -template class MQTTDisableAction : public Action { +template class MQTTDisableAction final : public Action { public: MQTTDisableAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 443c983efe..d5ee4c6a9b 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -115,9 +115,9 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo // max_temp root[MQTT_MAX_TEMP] = traits.get_visual_max_temperature(); // target_temp_step - root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1; + root[MQTT_TARGET_TEMPERATURE_STEP] = roundf(traits.get_visual_target_temperature_step() * 10) * 0.1f; // current_temp_step - root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1; + root[MQTT_CURRENT_TEMPERATURE_STEP] = roundf(traits.get_visual_current_temperature_step() * 10) * 0.1f; // temperature units are always coerced to Celsius internally root[MQTT_TEMPERATURE_UNIT] = "C"; diff --git a/esphome/components/mqtt/mqtt_climate.h b/esphome/components/mqtt/mqtt_climate.h index f0715929d4..b862db85ae 100644 --- a/esphome/components/mqtt/mqtt_climate.h +++ b/esphome/components/mqtt/mqtt_climate.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTClimateComponent : public mqtt::MQTTComponent { +class MQTTClimateComponent final : public mqtt::MQTTComponent { public: MQTTClimateComponent(climate::Climate *device); void send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) override; diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index afc514609c..3bbc1cdfa3 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -319,7 +319,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) device_info[MQTT_DEVICE_MANUFACTURER] = "Raspberry Pi"; #elif defined(USE_BK72XX) device_info[MQTT_DEVICE_MANUFACTURER] = "Beken"; diff --git a/esphome/components/mqtt/mqtt_cover.h b/esphome/components/mqtt/mqtt_cover.h index f801af5d12..3b07733993 100644 --- a/esphome/components/mqtt/mqtt_cover.h +++ b/esphome/components/mqtt/mqtt_cover.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTCoverComponent : public mqtt::MQTTComponent { +class MQTTCoverComponent final : public mqtt::MQTTComponent { public: explicit MQTTCoverComponent(cover::Cover *cover); diff --git a/esphome/components/mqtt/mqtt_date.h b/esphome/components/mqtt/mqtt_date.h index 4a626becb2..1c24422856 100644 --- a/esphome/components/mqtt/mqtt_date.h +++ b/esphome/components/mqtt/mqtt_date.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateComponent : public mqtt::MQTTComponent { +class MQTTDateComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateComponent instance with the provided friendly_name and date * diff --git a/esphome/components/mqtt/mqtt_datetime.h b/esphome/components/mqtt/mqtt_datetime.h index d02d6f579c..09af806fe3 100644 --- a/esphome/components/mqtt/mqtt_datetime.h +++ b/esphome/components/mqtt/mqtt_datetime.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTDateTimeComponent : public mqtt::MQTTComponent { +class MQTTDateTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTDateTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_event.h b/esphome/components/mqtt/mqtt_event.h index e6d5b6f278..424de3f603 100644 --- a/esphome/components/mqtt/mqtt_event.h +++ b/esphome/components/mqtt/mqtt_event.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTEventComponent : public mqtt::MQTTComponent { +class MQTTEventComponent final : public mqtt::MQTTComponent { public: explicit MQTTEventComponent(event::Event *event); diff --git a/esphome/components/mqtt/mqtt_fan.h b/esphome/components/mqtt/mqtt_fan.h index 43ef67e733..ff984bb77d 100644 --- a/esphome/components/mqtt/mqtt_fan.h +++ b/esphome/components/mqtt/mqtt_fan.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTFanComponent : public mqtt::MQTTComponent { +class MQTTFanComponent final : public mqtt::MQTTComponent { public: explicit MQTTFanComponent(fan::Fan *state); diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 41981655ef..2ca8d70dd4 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { +class MQTTJSONLightComponent final : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); diff --git a/esphome/components/mqtt/mqtt_lock.h b/esphome/components/mqtt/mqtt_lock.h index 666882c73d..7f36a51789 100644 --- a/esphome/components/mqtt/mqtt_lock.h +++ b/esphome/components/mqtt/mqtt_lock.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTLockComponent : public mqtt::MQTTComponent { +class MQTTLockComponent final : public mqtt::MQTTComponent { public: explicit MQTTLockComponent(lock::Lock *a_lock); diff --git a/esphome/components/mqtt/mqtt_number.h b/esphome/components/mqtt/mqtt_number.h index 021a539988..5e21544691 100644 --- a/esphome/components/mqtt/mqtt_number.h +++ b/esphome/components/mqtt/mqtt_number.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTNumberComponent : public mqtt::MQTTComponent { +class MQTTNumberComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTNumberComponent instance with the provided friendly_name and number * diff --git a/esphome/components/mqtt/mqtt_select.h b/esphome/components/mqtt/mqtt_select.h index aaf174ff72..46140ad456 100644 --- a/esphome/components/mqtt/mqtt_select.h +++ b/esphome/components/mqtt/mqtt_select.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSelectComponent : public mqtt::MQTTComponent { +class MQTTSelectComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSelectComponent instance with the provided friendly_name and select * diff --git a/esphome/components/mqtt/mqtt_sensor.h b/esphome/components/mqtt/mqtt_sensor.h index e8202aa8e2..1d5ee8095c 100644 --- a/esphome/components/mqtt/mqtt_sensor.h +++ b/esphome/components/mqtt/mqtt_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSensorComponent : public mqtt::MQTTComponent { +class MQTTSensorComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTSensorComponent instance with the provided friendly_name and sensor * diff --git a/esphome/components/mqtt/mqtt_switch.h b/esphome/components/mqtt/mqtt_switch.h index 5f6cb841fd..f35784ed5c 100644 --- a/esphome/components/mqtt/mqtt_switch.h +++ b/esphome/components/mqtt/mqtt_switch.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTSwitchComponent : public mqtt::MQTTComponent { +class MQTTSwitchComponent final : public mqtt::MQTTComponent { public: explicit MQTTSwitchComponent(switch_::Switch *a_switch); diff --git a/esphome/components/mqtt/mqtt_text.h b/esphome/components/mqtt/mqtt_text.h index 8ae0b9e29a..d42eefc690 100644 --- a/esphome/components/mqtt/mqtt_text.h +++ b/esphome/components/mqtt/mqtt_text.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextComponent : public mqtt::MQTTComponent { +class MQTTTextComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTextComponent instance with the provided friendly_name and text * diff --git a/esphome/components/mqtt/mqtt_text_sensor.h b/esphome/components/mqtt/mqtt_text_sensor.h index d8f9315c1e..1fe9651fa1 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.h +++ b/esphome/components/mqtt/mqtt_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTextSensor : public mqtt::MQTTComponent { +class MQTTTextSensor final : public mqtt::MQTTComponent { public: explicit MQTTTextSensor(text_sensor::TextSensor *sensor); diff --git a/esphome/components/mqtt/mqtt_time.h b/esphome/components/mqtt/mqtt_time.h index cf5780da2d..3e60176e90 100644 --- a/esphome/components/mqtt/mqtt_time.h +++ b/esphome/components/mqtt/mqtt_time.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTTimeComponent : public mqtt::MQTTComponent { +class MQTTTimeComponent final : public mqtt::MQTTComponent { public: /** Construct this MQTTTimeComponent instance with the provided friendly_name and time * diff --git a/esphome/components/mqtt/mqtt_update.h b/esphome/components/mqtt/mqtt_update.h index ec1adb1fcd..04b0b09da1 100644 --- a/esphome/components/mqtt/mqtt_update.h +++ b/esphome/components/mqtt/mqtt_update.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTUpdateComponent : public mqtt::MQTTComponent { +class MQTTUpdateComponent final : public mqtt::MQTTComponent { public: explicit MQTTUpdateComponent(update::UpdateEntity *update); diff --git a/esphome/components/mqtt/mqtt_valve.h b/esphome/components/mqtt/mqtt_valve.h index d3b724a8ba..dd2cca514a 100644 --- a/esphome/components/mqtt/mqtt_valve.h +++ b/esphome/components/mqtt/mqtt_valve.h @@ -10,7 +10,7 @@ namespace esphome::mqtt { -class MQTTValveComponent : public mqtt::MQTTComponent { +class MQTTValveComponent final : public mqtt::MQTTComponent { public: explicit MQTTValveComponent(valve::Valve *valve); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 229c0586ab..739e8456ee 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeSensor : public sensor::Sensor, public Component { +class MQTTSubscribeSensor final : public sensor::Sensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index f218bf2a8a..8641825fca 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -10,7 +10,7 @@ namespace esphome::mqtt_subscribe { -class MQTTSubscribeTextSensor : public text_sensor::TextSensor, public Component { +class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Component { public: void set_parent(mqtt::MQTTClientComponent *parent) { parent_ = parent; } void set_topic(const std::string &topic) { topic_ = topic; } diff --git a/esphome/components/ms5611/ms5611.h b/esphome/components/ms5611/ms5611.h index c6ad5b231a..535acdd357 100644 --- a/esphome/components/ms5611/ms5611.h +++ b/esphome/components/ms5611/ms5611.h @@ -6,7 +6,7 @@ namespace esphome::ms5611 { -class MS5611Component : public PollingComponent, public i2c::I2CDevice { +class MS5611Component final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 8f9cc9cb88..f2c4d65f13 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -10,7 +10,7 @@ namespace esphome::ms8607 { Class for I2CDevice used to communicate with the Humidity sensor on the chip. See MS8607Component instead */ -class MS8607HumidityDevice : public i2c::I2CDevice { +class MS8607HumidityDevice final : public i2c::I2CDevice { public: uint8_t get_address() { return address_; } }; @@ -30,9 +30,9 @@ class MS8607HumidityDevice : public i2c::I2CDevice { - https://github.com/adafruit/Adafruit_MS8607 - https://github.com/sparkfun/SparkFun_PHT_MS8607_Arduino_Library */ -class MS8607Component : public PollingComponent, public i2c::I2CDevice { +class MS8607Component final : public PollingComponent, public i2c::I2CDevice { public: - virtual ~MS8607Component() = default; + ~MS8607Component() = default; void setup() override; void update() override; void dump_config() override; diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index f23fcfc8ea..ecde0cb117 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "msa3xx"; const uint8_t MSA_3XX_PART_ID = 0x13; const float GRAVITY_EARTH = 9.80665f; -const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9); // LSB to 1 LSB = 3.9mg = 0.0039g +const float LSB_COEFF = 1000.0f / (GRAVITY_EARTH * 3.9f); // LSB to 1 LSB = 3.9mg = 0.0039g const float G_OFFSET_MIN = -4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe const float G_OFFSET_MAX = 4.5f; // -127...127 LSB = +- 0.4953g = +- 4.857 m/s^2 => +- 4.5 for the safe diff --git a/esphome/components/msa3xx/msa3xx.h b/esphome/components/msa3xx/msa3xx.h index 345afc50ab..212ee10a48 100644 --- a/esphome/components/msa3xx/msa3xx.h +++ b/esphome/components/msa3xx/msa3xx.h @@ -211,7 +211,7 @@ union RegTapDuration { uint8_t raw{0x04}; }; -class MSA3xxComponent : public PollingComponent, public i2c::I2CDevice { +class MSA3xxComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index 60b113079e..ababfd7fc7 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -8,7 +8,7 @@ namespace esphome::my9231 { /// MY9231 float output component. -class MY9231OutputComponent : public Component { +class MY9231OutputComponent final : public Component { public: class Channel; void set_pin_di(GPIOPin *pin_di) { pin_di_ = pin_di; } @@ -26,7 +26,7 @@ class MY9231OutputComponent : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(MY9231OutputComponent *parent) { parent_ = parent; } void set_channel(uint16_t channel) { channel_ = channel; } diff --git a/esphome/components/nau7802/nau7802.h b/esphome/components/nau7802/nau7802.h index 67f36ca677..c53a018234 100644 --- a/esphome/components/nau7802/nau7802.h +++ b/esphome/components/nau7802/nau7802.h @@ -47,7 +47,7 @@ enum NAU7802CalibrationModes { NAU7802_CALIBRATE_GAIN = 0b11, }; -class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class NAU7802Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void set_samples_per_second(NAU7802SPS sps) { this->sps_ = sps; } void set_ldo_voltage(NAU7802LDO ldo) { this->ldo_ = ldo; } @@ -97,18 +97,18 @@ class NAU7802Sensor : public sensor::Sensor, public PollingComponent, public i2c }; template -class NAU7802CalbrateExternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateExternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_external_offset(); } }; template -class NAU7802CalbrateInternalOffsetAction : public Action, public Parented { +class NAU7802CalbrateInternalOffsetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_internal_offset(); } }; -template class NAU7802CalbrateGainAction : public Action, public Parented { +template class NAU7802CalbrateGainAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->calibrate_gain(); } }; diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index b662293ab5..0f4bcb3e16 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -25,6 +25,20 @@ NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) IPAddress = network_ns.class_("IPAddress") +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register network connectivity as a provisioning source. + + The network component is auto-loaded whenever an interface (wifi, ethernet, ...) + is configured, so a device with connectivity always has this source: it is + considered provisioned once it has connected via any interface, and + `provisioning:` is valid without another source. + """ + from esphome.components import provisioning + + provisioning.register_source("network") + return config + + def ip_address_literal(ip: str | int | None) -> cg.MockObj: """Generate an IPAddress with compile-time initialization instead of runtime parsing. @@ -59,6 +73,19 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj: return IPAddress(str(ip)) +def add_use_address(var: cg.MockObj, use_address: str) -> None: + """Generate a set_use_address() call only when the address must be baked in. + + The default ".local" is not stored in the firmware; it is rebuilt at + runtime from the device name (see network::get_use_address_to()), which also + picks up the MAC suffix when name_add_mac_suffix is enabled. A compile-time + string could never include that suffix, so baking it in would log the wrong + address. + """ + if use_address != f"{CORE.name}.local": + cg.add(var.set_use_address(use_address)) + + def require_high_performance_networking() -> None: """Request high performance networking for network and WiFi. @@ -115,36 +142,41 @@ def validate_ipv6(value: bool) -> bool: return value -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(NetworkComponent), - cv.SplitDefault( - CONF_ENABLE_IPV6, - bk72xx=False, - esp32=False, - esp8266=False, - host=False, - rp2040=False, - nrf52=True, - ): cv.All( - cv.boolean, - cv.Any( - cv.require_framework_version( - bk72xx_arduino=cv.Version(1, 7, 0), - esp_idf=cv.Version(0, 0, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp8266_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), - nrf52_zephyr=cv.Version(0, 0, 0), +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(NetworkComponent), + cv.SplitDefault( + CONF_ENABLE_IPV6, + bk72xx=False, + esp32=False, + esp8266=False, + host=False, + rp2=False, + nrf52=True, + ): cv.All( + cv.boolean, + cv.Any( + cv.require_framework_version( + bk72xx_arduino=cv.Version(1, 7, 0), + esp_idf=cv.Version(0, 0, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp8266_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + nrf52_zephyr=cv.Version(0, 0, 0), + ), + cv.boolean_false, ), - cv.boolean_false, + validate_ipv6, ), - validate_ipv6, - ), - cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, - cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), - } + cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, + cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All( + cv.boolean, cv.only_on_esp32 + ), + } + ), + _register_provisioning_source, ) @@ -221,6 +253,27 @@ async def to_code(config): zephyr_add_prj_conf("NET_IPV6", True) zephyr_add_prj_conf("NET_TCP", True) zephyr_add_prj_conf("NET_UDP", True) + # The nRF Connect SDK replaces mbedTLS with PSA/Oberon crypto and does not provide the + # legacy mbedtls_md5() symbol that Zephyr's RFC 6528 TCP ISN generator links against + # (selecting MBEDTLS_MAC_MD5_ENABLED does not bring in the legacy C API here). Disable it so + # TCP links; Zephyr falls back to sys_rand32_get() for the ISN (randomized, but not the + # RFC 6528 keyed hash). + zephyr_add_prj_conf("NET_TCP_ISN_RFC6528", False) + # Enlarge the Zephyr network buffer pool and TCP windows for the Thread path. + # Zephyr's defaults are tiny: NET_BUF_TX_COUNT=16 * NET_BUF_DATA_SIZE=128 is only + # ~2 KB of TX data -- barely one 1280-byte IPv6 packet once 6LoWPAN fragments it. + # The ESPHome API entity-sync burst overruns that instantly, so socket writes fail + # with ENOBUFS ("Buffer full") and the connection is dropped. ESP32 sidesteps this + # by enlarging the lwIP TCP window (CONFIG_LWIP_TCP_* above); give Zephyr the + # equivalent headroom, sized to RAM and the Thread 1280-byte MTU (not ESP32's 64 KB). + # The bounded send window also provides flow control so TCP stops queueing past + # what the buffer pool can hold instead of erroring. + zephyr_add_prj_conf("NET_PKT_RX_COUNT", 24) + zephyr_add_prj_conf("NET_PKT_TX_COUNT", 24) + zephyr_add_prj_conf("NET_BUF_RX_COUNT", 48) + zephyr_add_prj_conf("NET_BUF_TX_COUNT", 48) + zephyr_add_prj_conf("NET_TCP_MAX_RECV_WINDOW_SIZE", 2280) + zephyr_add_prj_conf("NET_TCP_MAX_SEND_WINDOW_SIZE", 2280) if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) @@ -242,7 +295,7 @@ async def to_code(config): cg.add_build_flag("-DCONFIG_IPV6") if CORE.is_esp8266: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY") - if CORE.is_rp2040: + if CORE.is_rp2: cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6") # Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it # emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index dde15940e4..2e76a95673 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,7 +4,7 @@ #include "esphome/core/component.h" namespace esphome::network { -class NetworkComponent : public Component { +class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 79ddd3844c..ae250c6a1f 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -1,5 +1,7 @@ #include "util.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #ifdef USE_NETWORK namespace esphome::network { @@ -20,6 +22,27 @@ bool is_disabled() { return false; } +const char *get_use_address_to(std::span buf) { + // Global component pointers are guaranteed to be set by component constructors when USE_* is defined + const char *addr = nullptr; +#if defined(USE_ETHERNET) + addr = ethernet::global_eth_component->get_use_address(); +#elif defined(USE_MODEM) + addr = modem::global_modem_component->get_use_address(); +#elif defined(USE_WIFI) + addr = wifi::global_wifi_component->get_use_address(); +#elif defined(USE_OPENTHREAD) + addr = openthread::global_openthread_component->get_use_address(); +#endif + if (addr != nullptr && addr[0] != '\0') + return addr; + // No explicit use_address configured: the address is the runtime device name + // (which includes the MAC suffix when name_add_mac_suffix is enabled) plus ".local" + const auto &name = App.get_name(); + make_name_with_suffix_to(buf.data(), buf.size(), name.c_str(), name.size(), '.', "local", 5); + return buf.data(); +} + network::IPAddresses get_ip_addresses() { #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index e4e8a01f8c..17a2ff0977 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK +#include #include #include "esphome/core/helpers.h" #include "ip_address.h" @@ -53,30 +54,12 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { /// Return whether the network is disabled (only wifi for now) bool is_disabled(); -/// Get the active network hostname -ESPHOME_ALWAYS_INLINE inline const char *get_use_address() { - // Global component pointers are guaranteed to be set by component constructors when USE_* is defined -#ifdef USE_ETHERNET - return ethernet::global_eth_component->get_use_address(); -#endif - -#ifdef USE_MODEM - return modem::global_modem_component->get_use_address(); -#endif - -#ifdef USE_WIFI - return wifi::global_wifi_component->get_use_address(); -#endif - -#ifdef USE_OPENTHREAD - return openthread::global_openthread_component->get_use_address(); -#endif - -#if !defined(USE_ETHERNET) && !defined(USE_MODEM) && !defined(USE_WIFI) && !defined(USE_OPENTHREAD) - // Fallback when no network component is defined (e.g., host platform) - return ""; -#endif -} +/// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator +static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; +/// Get the active network address for logging. Returns the explicitly configured +/// use_address when one was set, otherwise formats ".local" from the runtime +/// device name into buf (so it includes the MAC suffix from name_add_mac_suffix). +const char *get_use_address_to(std::span buf); IPAddresses get_ip_addresses(); } // namespace esphome::network diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index 38f449dc03..d51155b0a4 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -19,7 +19,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "nextion_upload_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index e039dae615..0226c65be6 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -7,7 +7,7 @@ namespace esphome::nextion { -template class NextionSetBrightnessAction : public Action { +template class NextionSetBrightnessAction final : public Action { public: explicit NextionSetBrightnessAction(Nextion *component) : component_(component) {} @@ -24,7 +24,7 @@ template class NextionSetBrightnessAction : public Action Nextion *component_; }; -template class NextionPublishFloatAction : public Action { +template class NextionPublishFloatAction final : public Action { public: explicit NextionPublishFloatAction(NextionComponent *component) : component_(component) {} @@ -47,7 +47,7 @@ template class NextionPublishFloatAction : public Action NextionComponent *component_; }; -template class NextionPublishTextAction : public Action { +template class NextionPublishTextAction final : public Action { public: explicit NextionPublishTextAction(NextionComponent *component) : component_(component) {} @@ -70,7 +70,7 @@ template class NextionPublishTextAction : public Action { NextionComponent *component_; }; -template class NextionPublishBoolAction : public Action { +template class NextionPublishBoolAction final : public Action { public: explicit NextionPublishBoolAction(NextionComponent *component) : component_(component) {} diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index 7637957222..9970db1c01 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -8,9 +8,9 @@ namespace esphome::nextion { class NextionBinarySensor; -class NextionBinarySensor : public NextionComponent, - public binary_sensor::BinarySensorInitiallyOff, - public PollingComponent { +class NextionBinarySensor final : public NextionComponent, + public binary_sensor::BinarySensorInitiallyOff, + public PollingComponent { public: NextionBinarySensor(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 4ebc717552..bdc66adb70 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1,6 +1,7 @@ #include "nextion.h" #include +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" @@ -352,8 +353,9 @@ void Nextion::loop() { this->connection_state_.ignore_is_setup_ = false; } - this->process_serial_(); // Receive serial data - this->process_nextion_commands_(); // Process nextion return commands + this->process_serial_(); // Receive serial data + this->process_nextion_commands_(); // Process nextion return commands + this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data if (!this->connection_state_.nextion_reports_is_setup_) { if (this->started_ms_ == 0) @@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() { this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1); } + ESP_LOGN(TAG, "Loop end"); + this->process_serial_(); +} // Nextion::process_nextion_commands_() + +void Nextion::purge_stale_queue_entries_() { const uint32_t ms = App.get_loop_component_start_time(); if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && @@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() { } } } - ESP_LOGN(TAG, "Loop end"); - // App.feed_wdt(); Remove before master merge - this->process_serial_(); -} // Nextion::process_nextion_commands_() +} void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) { this->set_nextion_sensor_state(static_cast(queue_type), name, state); @@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { new (nextion_queue) nextion::NextionQueue(); // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); @@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va } new (nextion_queue) nextion::NextionQueue(); - nextion_queue->component = new nextion::NextionComponentBase; + nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase; + if (nextion_queue->component == nullptr) { + ESP_LOGW(TAG, "Component alloc failed"); + nextion_queue->~NextionQueue(); + allocator.deallocate(nextion_queue, 1); + return; + } nextion_queue->component->set_variable_name(variable_name); nextion_queue->queue_time = App.get_loop_component_start_time(); nextion_queue->pending_command = command; // Store command for retry diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index ef030e71da..7dc5a4fe44 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -76,7 +76,7 @@ class NextionCommandPacer { }; #endif // USE_NEXTION_COMMAND_SPACING -class Nextion : public NextionBase, public PollingComponent, public uart::UARTDevice { +class Nextion final : public NextionBase, public PollingComponent, public uart::UARTDevice { public: #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP /** @@ -1486,6 +1486,10 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void process_nextion_commands_(); void process_serial_(); + /// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the + /// display sends no data at all (disconnected or asleep), which would otherwise grow the queue + /// without bound. + void purge_stale_queue_entries_(); uint16_t touch_sleep_timeout_ = 0; uint8_t wake_up_page_ = 255; #ifdef USE_NEXTION_CONF_START_UP_PAGE diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index a332d342ee..a356d54e2f 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -176,7 +176,7 @@ void Nextion::goto_page(const char *page) { this->add_no_result_to_queue_with_pr void Nextion::goto_page(uint8_t page) { this->add_no_result_to_queue_with_printf_("page", "page %i", page); } void Nextion::set_backlight_brightness(float brightness) { - if (brightness < 0 || brightness > 1.0) { + if (brightness < 0 || brightness > 1.0f) { ESP_LOGD(TAG, "Brightness out of bounds (0-1.0)"); return; } diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index 72e3982b3a..bc0875fdff 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSensor; -class NextionSensor : public NextionComponent, public sensor::Sensor, public PollingComponent { +class NextionSensor final : public NextionComponent, public sensor::Sensor, public PollingComponent { public: NextionSensor(NextionBase *nextion) { this->nextion_ = nextion; } void send_state_to_nextion() override { this->set_state(this->state, false, true); }; diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index 7e0593d217..2cac733b49 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionSwitch; -class NextionSwitch : public NextionComponent, public switch_::Switch, public PollingComponent { +class NextionSwitch final : public NextionComponent, public switch_::Switch, public PollingComponent { public: NextionSwitch(NextionBase *nextion) { this->nextion_ = nextion; } diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 42cd5dcef4..5ef2bb222f 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -8,7 +8,7 @@ namespace esphome::nextion { class NextionTextSensor; -class NextionTextSensor : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { +class NextionTextSensor final : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { public: NextionTextSensor(NextionBase *nextion) { this->nextion_ = nextion; } void update() override; diff --git a/esphome/components/nfc/automation.h b/esphome/components/nfc/automation.h index 0ac3e3b8b6..ec3a979b64 100644 --- a/esphome/components/nfc/automation.h +++ b/esphome/components/nfc/automation.h @@ -7,7 +7,7 @@ namespace esphome::nfc { -class NfcOnTagTrigger : public Trigger { +class NfcOnTagTrigger final : public Trigger { public: void process(const std::unique_ptr &tag); }; diff --git a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h index b3448a57cc..6354e16967 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.h @@ -8,10 +8,10 @@ namespace esphome::nfc { -class NfcTagBinarySensor : public binary_sensor::BinarySensor, - public Component, - public NfcTagListener, - public Parented { +class NfcTagBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public NfcTagListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/noblex/noblex.h b/esphome/components/noblex/noblex.h index 62070e5dee..e505a5ba3f 100644 --- a/esphome/components/noblex/noblex.h +++ b/esphome/components/noblex/noblex.h @@ -8,7 +8,7 @@ namespace esphome::noblex { const uint8_t NOBLEX_TEMP_MIN = 16; // Celsius const uint8_t NOBLEX_TEMP_MAX = 30; // Celsius -class NoblexClimate : public climate_ir::ClimateIR { +class NoblexClimate final : public climate_ir::ClimateIR { public: NoblexClimate() : climate_ir::ClimateIR(NOBLEX_TEMP_MIN, NOBLEX_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/npi19/npi19.h b/esphome/components/npi19/npi19.h index d1f74141ac..f18a0989de 100644 --- a/esphome/components/npi19/npi19.h +++ b/esphome/components/npi19/npi19.h @@ -7,7 +7,7 @@ namespace esphome::npi19 { /// This class implements support for the npi19 pressure and temperature i2c sensors. -class NPI19Component : public PollingComponent, public i2c::I2CDevice { +class NPI19Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 56367d0b26..8d522a8740 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -4,6 +4,7 @@ import asyncio import logging from pathlib import Path import re +import shutil import subprocess from esphome import pins @@ -52,7 +53,12 @@ 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.helpers import write_file_if_changed +from esphome.framework_helpers import ( + get_project_compile_flags, + get_project_link_flags, + run_command_ok, +) +from esphome.helpers import rmtree, write_file_if_changed from esphome.storage_json import StorageJSON from esphome.types import ConfigType @@ -63,7 +69,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 @@ -73,6 +79,11 @@ AUTO_LOAD = ["zephyr", "preferences"] IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) +# Default framework versions per toolchain. The sdk-nrf one also keys the CI +# sdk-nrf install cache and pins the clang-tidy project's SDK. +RECOMMENDED_PLATFORMIO_VERSION = "2.6.1-b" +RECOMMENDED_SDK_NRF_VERSION = "2.9.2" + FAKE_BOARD_MANIFEST = """ { "frameworks": [ @@ -99,9 +110,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,9 +120,19 @@ 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.SDK_NRF) + 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" + default_version = ( + RECOMMENDED_PLATFORMIO_VERSION + if CORE.using_toolchain_platformio + else RECOMMENDED_SDK_NRF_VERSION + ) config = { **config, CONF_FRAMEWORK: {**config[CONF_FRAMEWORK], CONF_VERSION: default_version}, @@ -147,6 +165,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() @@ -176,6 +200,7 @@ DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" CONF_DCDC = "dcdc" +CONF_LIBC_NANO = "libc_nano" CONF_REG0 = "reg0" CONF_UICR_ERASE = "uicr_erase" @@ -208,7 +233,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(KEY_BOOTLOADER): cv.one_of(*BOOTLOADERS, lower=True), cv.Optional(CONF_DFU): _dfu_schema, - cv.Optional(CONF_DCDC, default=True): cv.boolean, + cv.Optional(CONF_DCDC): cv.boolean, cv.Optional(CONF_REG0): cv.Schema( { cv.Required(CONF_VOLTAGE): cv.All( @@ -224,7 +249,10 @@ CONFIG_SCHEMA = cv.All( ): cv.Schema( { cv.Optional(CONF_VERSION): cv.string_strict, - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + cv.Optional(CONF_LIBC_NANO, default=True): cv.boolean, + cv.Optional( + CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY + ): cv.Schema( { cv.Optional( CONF_ENABLE_OTA_ROLLBACK, default=True @@ -233,9 +261,11 @@ CONFIG_SCHEMA = cv.All( ), } ), + cv.Optional(CONF_TOOLCHAIN): _validate_toolchain, cv.GenerateID(CONF_CDC_ACM): cv.declare_id(CdcAcm), } ), + _resolve_toolchain, set_framework, ) @@ -247,6 +277,7 @@ def _validate_mcumgr(config): def _final_validate(config): + if CONF_DFU in config: _validate_mcumgr(config) if config[KEY_BOOTLOADER] == BOOTLOADER_ADAFRUIT: @@ -257,6 +288,13 @@ def _final_validate(config): conf = config[CONF_FRAMEWORK] advanced = conf[CONF_ADVANCED] + if conf[CONF_LIBC_NANO] and "logger" in CORE.loaded_integrations: + _LOGGER.warning( + "Logger is enabled with newlib-nano (libc_nano: true). Some format specifiers " + "such as %%zu are not supported and will print incorrectly. " + "Set 'libc_nano: false' under 'framework:' to use the full newlib." + ) + if advanced[CONF_ENABLE_OTA_ROLLBACK]: # "disabled: false" means safe mode *is* enabled. safe_mode_config = full_config.get(CONF_SAFE_MODE, {CONF_DISABLED: True}) @@ -331,16 +369,17 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] - if framework_ver < cv.Version(2, 9, 2): - zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) - else: - zephyr_add_overlay( - f""" - ®1 {{ - regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; - }}; - """ - ) + if CONF_DCDC in config: + if framework_ver < cv.Version(2, 9, 2): + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + else: + zephyr_add_overlay( + f""" + ®1 {{ + regulator-initial-mode = <{"NRF5X_REG_MODE_DCDC" if config[CONF_DCDC] else "NRF5X_REG_MODE_LDO"}>; + }}; + """ + ) if reg0_config := config.get(CONF_REG0): value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) @@ -353,6 +392,9 @@ async def to_code(config: ConfigType) -> None: # Enable OTA rollback support if advanced[CONF_ENABLE_OTA_ROLLBACK]: cg.add_define("USE_OTA_ROLLBACK") + zephyr_add_prj_conf("NEWLIB_LIBC", True) + zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) + zephyr_add_prj_conf("NEWLIB_LIBC_NANO", conf[CONF_LIBC_NANO]) # c++ support if framework_ver < cv.Version(2, 9, 2): zephyr_add_prj_conf("CPLUSPLUS", True) @@ -394,6 +436,17 @@ async def _dfu_to_code(dfu_config): def copy_files() -> None: """Copy files to the build directory.""" + # Library conversion to Zephyr modules is wired into the sdk-nrf + # CMakeLists only; the PlatformIO toolchain's forked platform package + # cannot compile external libraries at all, so the build would fail at + # link time anyway. Fail fast with a clear message instead. + if CORE.using_toolchain_platformio and CORE.platformio_libraries: + raise EsphomeError( + f"Libraries ({', '.join(sorted(CORE.platformio_libraries))}) are " + "not supported on the nRF52 'platformio' toolchain; use toolchain " + "'sdk-nrf' to build them as Zephyr modules." + ) + if CORE.using_toolchain_platformio and ( zephyr_data()[KEY_BOOTLOADER] == BOOTLOADER_MCUBOOT or zephyr_data()[KEY_BOARD] == "xiao_ble" @@ -411,8 +464,8 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" - HEX_PATH = "zephyr/zephyr.hex" - HEX_MERGED_PATH = "zephyr/merged.hex" + HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1, only generated when OTA is disabled + HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent if (build_dir / UF2_PATH).is_file(): @@ -470,6 +523,16 @@ def upload_program(config: ConfigType, args, host: str) -> bool: from esphome.__main__ import check_permissions from esphome.upload_targets import PortType, get_port_type + if KEY_ZEPHYR not in CORE.data: + platform_config = config.get(CORE.target_platform) + if not platform_config: + raise EsphomeError( + "nRF52 platform configuration is missing; " + "please re-validate and recompile." + ) + set_core_data(platform_config) + set_framework(platform_config) + mcumgr_device: str | None = None if get_port_type(host) == PortType.SERIAL: @@ -478,17 +541,122 @@ def upload_program(config: ConfigType, args, host: str) -> bool: mcumgr_device = host else: if not CORE.using_toolchain_platformio: - raise EsphomeError("Not implemented yet") - result = _upload_using_platformio(config, host, ["-t", "upload"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio serial upload + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader not in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + raise EsphomeError("Not implemented yet") + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + if not dfu_package.is_file(): + raise EsphomeError("Firmware not found. Please compile first.") + import time as _time + + import serial as _serial + import serial.tools.list_ports as _list_ports + + try: + ser = _serial.Serial(host, baudrate=1200, timeout=1) + ser.close() + except _serial.SerialException as err: + raise EsphomeError(f"Failed to open {host}: {err}") from err + + # Wait for device to reset (port disappears) + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host not in {p.device for p in _list_ports.comports()}: + break + else: + _LOGGER.warning( + "Device did not leave %s within 5 s; " + "it may not have entered bootloader mode", + host, + ) + + # Wait for DFU port to reappear + deadline = _time.monotonic() + 10 + while _time.monotonic() < deadline: + _time.sleep(0.1) + if host in {p.device for p in _list_ports.comports()}: + break + else: + raise EsphomeError( + f"DFU port {host!r} did not reappear within 10 s. " + "Check that the device entered DFU mode." + ) + + # Wait for udev to finish setting up device permissions + deadline = _time.monotonic() + 5 + while _time.monotonic() < deadline: + try: + check_permissions(host) + break + except EsphomeError: + _time.sleep(0.05) + else: + check_permissions(host) # raises with helpful message + + python = str(paths["python_executable"]) + if not run_command_ok( + [ + python, + "-m", + "nordicsemi.__main__", + "dfu", + "serial", + "-pkg", + str(dfu_package), + "-p", + host, + "-b", + "115200", + "--singlebank", + ], + env=env, + stream_output=True, + ): + raise EsphomeError("nRF52 serial DFU upload failed") + else: + result = _upload_using_platformio(config, host, ["-t", "upload"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: serial upload if host == "PYOCD": - result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) - if result != 0: - raise EsphomeError(f"Upload failed with result: {result}") - return True # Handled: platformio PYOCD upload + if not CORE.using_toolchain_platformio: + check_and_install() + paths = get_build_paths() + env = get_build_env() + build_dir = CORE.relative_pioenvs_path(CORE.name) + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "flash", + "--runner", + "pyocd", + "-d", + str(build_dir), + ] + if not run_command_ok( + west_cmd, + env=env, + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 pyocd flash failed") + else: + result = _upload_using_platformio(config, host, ["-t", "flash_pyocd"]) + if result != 0: + raise EsphomeError(f"Upload failed with result: {result}") + return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths from .ble_logger import is_mac_address @@ -554,10 +722,22 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> addr2line = find_tool("addr2line") if addr2line is None: return False - elf = CORE.relative_pioenvs_path(CORE.name, "firmware.elf") - if not elf.exists(): - _LOGGER.warning("%s does not exists", elf) + + candidates = [ + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "zephyr", "zephyr.elf"), + CORE.relative_pioenvs_path(CORE.name, "firmware.elf"), + ] + + elf = next((path for path in candidates if path.exists()), None) + + if elf is None: + _LOGGER.warning( + "None of the expected ELF files exist:\n%s", + "\n".join(str(p) for p in candidates), + ) return False + _LOGGER.error("=== CRASH ===") _LOGGER.error("PC: %s", _addr2line(addr2line, elf, pc)) _LOGGER.error("LR: %s", _addr2line(addr2line, elf, lr)) @@ -565,6 +745,68 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +def _generate_cmake_lists() -> bool: + """Write the project CMakeLists.txt, returning True if it changed.""" + compile_flags = get_project_compile_flags() + link_flags = get_project_link_flags() + + # Convert any PlatformIO libraries added via cg.add_library() into Zephyr + # modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set + # before find_package(Zephyr) so the modules are picked up). Only + # framework-agnostic libraries actually compile under Zephyr. + from esphome.components.zephyr.library import generate_zephyr_modules + + module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values())) + + lines = [ + "cmake_minimum_required(VERSION 3.20.0)", + "", + 'set(Zephyr_DIR "$ENV{ZEPHYR_BASE}/share/zephyr-package/cmake/")', + "", + ] + + if module_dirs: + modules = ";".join(str(d).replace("\\", "/") for d in module_dirs) + lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""] + + lines += [ + "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], + ")", + ] + + return write_file_if_changed( + CORE.relative_build_path("zephyr", "CMakeLists.txt"), + "\n".join(lines) + "\n", + ) + + +def _copy_if_exists(src: Path, dst: Path) -> None: + if src.is_file(): + shutil.copy2(src, dst) + + def run_compile(args, config: ConfigType) -> bool: if CORE.using_toolchain_platformio: return False @@ -574,4 +816,88 @@ 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() + + cmake_lists_changed = _generate_cmake_lists() + + board = zephyr_data()[KEY_BOARD] + build_dir = CORE.relative_pioenvs_path(CORE.name) + source_dir = CORE.relative_build_path("zephyr") + + # A missing CMake cache (dropped by zephyr's copy_files() on config + # change) or a changed CMakeLists.txt requires a pristine build: Zephyr + # caches Kconfig/devicetree state that survives a plain cmake re-run. + # West can't do the wipe — its pristine modes only recognize a build dir + # by reading ZEPHYR_BASE from the very cache that was dropped. + if ( + cmake_lists_changed or not (build_dir / "CMakeCache.txt").is_file() + ) and build_dir.is_dir(): + _LOGGER.info("Build inputs changed, cleaning %s", build_dir) + rmtree(build_dir) + + 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") + + zephyr_dir = build_dir / "zephyr" + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + # SDK < 2.9.2 places artifacts directly in build_dir/zephyr/. + # SDK >= 2.9.2 nests them one level deeper (build_dir/zephyr/zephyr/); + # copy files to match get_download_types layout. + if framework_ver < cv.Version(2, 9, 2): + west_out = zephyr_dir + else: + west_out = zephyr_dir / "zephyr" + _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2") + _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") + _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") + + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + _GENPKG_PARAMS = { + BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + } + bootloader = zephyr_data()[KEY_BOOTLOADER] + if bootloader in ( + BOOTLOADER_ADAFRUIT, + BOOTLOADER_ADAFRUIT_NRF52_SD132, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + ): + hex_file = west_out / "zephyr.hex" + dfu_package = build_dir / "firmware.zip" + genpkg_cmd = [ + str(paths["python_executable"]), + "-m", + "nordicsemi.__main__", + "dfu", + "genpkg", + ] + if bootloader in _GENPKG_PARAMS: + dev_type, sd_req = _GENPKG_PARAMS[bootloader] + genpkg_cmd += ["--dev-type", dev_type, "--sd-req", sd_req] + genpkg_cmd += ["--application", str(hex_file), str(dfu_package)] + if not run_command_ok(genpkg_cmd, env=env, stream_output=True): + raise EsphomeError("Failed to create adafruit DFU package") + + return True diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py new file mode 100644 index 0000000000..2dd4b7bd09 --- /dev/null +++ b/esphome/components/nrf52/clang_tidy.py @@ -0,0 +1,249 @@ +"""Generate clang-tidy compile commands via the native sdk-nrf toolchain. + +Produces a ``compile_commands.json`` for the nrf52/Zephyr clang-tidy +environment **without an ESPHome YAML config**, mirroring +``esphome.espidf.clang_tidy``: generate a minimal Zephyr application, run a +configure-only west build with the native sdk-nrf toolchain, and let +``script/helpers_zephyr.py`` extract idedata from the resulting compile +commands. + +* the stub app is C++ so the compile commands carry C++ flags, matching how + clang-tidy analyzes ESPHome's sources; +* ``prj.conf`` enables the Kconfig superset ESPHome components need (BT, ADC, + mcumgr, zigbee) so their include paths land in the compile commands; +* the platform defines (USE_ZEPHYR, USE_NRF52) match what a real ESPHome + nrf52 build adds via its generated project. + +``ESPHOME_ZEPHYR_COMPILE_COMMANDS`` may point at an existing build's +``compile_commands.json`` to skip generation (fast iteration). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +TIDY_PROJECT_NAME = "esphome_tidy" + +# Analyzed against the native toolchain's default SDK version +# (RECOMMENDED_SDK_NRF_VERSION), which also keys the CI install cache. +_TIDY_BOARD = "adafruit_itsybitsy_nrf52840" + +# Never compiled (the build is configure-only): the file exists only so the +# app target emits a C++ compile command to harvest flags/includes from. +_TIDY_MAIN_CPP = "int main() { return 0; }\n" + +# Kconfig superset enabling every subsystem an ESPHome nrf52 component may +# use, so the compile commands carry all of their include paths. +_TIDY_PRJ_CONF = """\ +CONFIG_CPP=y +CONFIG_STD_CPP20=y +CONFIG_REQUIRES_FULL_LIBCPP=y +CONFIG_NEWLIB_LIBC=y +CONFIG_BT=y +CONFIG_ADC=y +# posix (time sets POSIX_CLOCK, socket sets POSIX_API); without it the +# Zephyr POSIX headers clash with the libc ones under analysis +CONFIG_POSIX_API=y +#mcumgr begin +CONFIG_NET_BUF=y +CONFIG_ZCBOR=y +CONFIG_MCUMGR=y +CONFIG_MCUMGR_GRP_IMG=y +CONFIG_IMG_MANAGER=y +CONFIG_STREAM_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_FLASH=y +CONFIG_IMG_ERASE_PROGRESSIVELY=y +CONFIG_BOOTLOADER_MCUBOOT=y +CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y +CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y +CONFIG_MCUMGR_TRANSPORT_UART=y +#mcumgr end +#zigbee begin +CONFIG_ZIGBEE=y +CONFIG_CRYPTO=y +CONFIG_NVS=y +CONFIG_SETTINGS=y +#zigbee end +""" + + +def _tidy_cmakelists(library_include_dirs: str) -> str: + # The defines a real ESPHome nrf52 build puts on the app target. + # ESPHOME_LOG_LEVEL must be set up front -- otherwise log.h's ``#ifndef`` + # sets it to NONE, a macro-redefined warning across nearly every source. + return f"""\ +# Auto-generated by ESPHome (clang-tidy compile-commands project) +cmake_minimum_required(VERSION 3.20.0) +set(Zephyr_DIR "$ENV{{ZEPHYR_BASE}}/share/zephyr-package/cmake/") +find_package(Zephyr REQUIRED) +project({TIDY_PROJECT_NAME}) +target_sources(app PRIVATE main.cpp) +target_compile_definitions(app PRIVATE + USE_ZEPHYR + USE_NRF52 + ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE +) +target_include_directories(app PRIVATE +{library_include_dirs} +) +""" + + +def _parse_lib_deps(platformio_ini: Path) -> list: + """Parse the nrf52 env's ``lib_deps`` from platformio.ini into Library specs. + + These are the PlatformIO libraries ESPHome components pull in via + ``cg.add_library`` (ArduinoJson, dlms_parser, ...); their headers must be + on the tidy translation unit's include path. Mirrors the pio nrf52 env's + ``lib_deps`` composition (``common.lib_deps_base`` + + ``common:idf-component-libs``). + """ + import configparser + + from esphome.core import Library + + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(platformio_ini) + + tokens: list[str] = [] + for section, key in ( + ("common", "lib_deps_base"), + ("common:idf-component-libs", "lib_deps"), + ): + if parser.has_option(section, key): + tokens += parser.get(section, key).splitlines() + + libs: list[Library] = [] + for token in tokens: + token = token.split(";", 1)[0].strip() # drop trailing ; comment + if not token or token.startswith(("${", "+<")): + continue + if "://" in token or ".git" in token: + libs.append(Library(token, None, token)) # git repository (with #ref) + elif "@" in token: + name, _, version = token.partition("@") + libs.append(Library(name, version)) + return libs + + +def _library_include_dirs(platformio_ini: Path) -> list[str]: + """Resolve the pio libraries and return their include roots.""" + from esphome.platformio.library import LibraryBackend, convert_libraries + + dirs: list[str] = [] + + def emit(component) -> None: + build = component.data.get("build", {}) + candidates = {build.get("includeDir", "include"), build.get("srcDir", "src")} + candidates.update({"src", "."}) + for candidate in sorted(candidates): + path = (component.path / candidate).resolve() + if path.is_dir(): + dirs.append(str(path)) + + backend = LibraryBackend( + platform="nordicnrf52", framework="zephyr", emit=emit, cache_key="zephyr" + ) + convert_libraries(_parse_lib_deps(platformio_ini), backend) + return sorted(set(dirs)) + + +def _setup_core(work_dir: Path) -> None: + """Point CORE at the tidy project + SDK version, without any YAML config.""" + from esphome.components.zephyr.const import KEY_ZEPHYR + import esphome.config_validation as cv + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, + ) + from esphome.core import CORE + + from . import RECOMMENDED_SDK_NRF_VERSION + + CORE.name = TIDY_PROJECT_NAME + # config_path's parent is the data-dir root for per-run artifacts. The + # sdk-nrf install is in the global cache dir, independent of this path. + CORE.config_path = work_dir.parent / "tidy.yaml" + CORE.build_path = work_dir + CORE.toolchain = Toolchain.SDK_NRF + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_NRF52 + CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + RECOMMENDED_SDK_NRF_VERSION + ) + + +def generate_compile_commands(work_dir: Path, platformio_ini: Path) -> Path: + """Generate the tidy Zephyr project and run a configure-only west build. + + Returns the path to the generated ``compile_commands.json``. + """ + from esphome.core import EsphomeError + from esphome.framework_helpers import run_command_ok + from esphome.helpers import rmtree + + from .framework import check_and_install, get_build_env, get_build_paths + + # Surface ESPHome's INFO logs (sdk-nrf download/west update) -- they go + # through logging, which the clang-tidy script otherwise leaves at + # WARNING, so the first-run installation looks silent without this. + logging.basicConfig(level=logging.INFO, format="%(message)s") + + _setup_core(work_dir) + check_and_install() + + library_include_dirs = "\n".join( + f' "{d}"' for d in _library_include_dirs(platformio_ini) + ) + source_dir = work_dir / "zephyr" + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "CMakeLists.txt").write_text( + _tidy_cmakelists(library_include_dirs), encoding="utf-8" + ) + (source_dir / "main.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8") + (source_dir / "prj.conf").write_text(_TIDY_PRJ_CONF, encoding="utf-8") + + # Always configure from scratch: west can't pristine a dir whose CMake + # cache is stale/missing, and a configure-only run is cheap. + build_dir = work_dir / "build" + if build_dir.is_dir(): + rmtree(build_dir) + + paths = get_build_paths() + # Build only the generated-headers target (syscall_list.h, offsets.h, ...) + # on top of the configure: clang-tidy needs those headers to exist, but a + # full firmware build would be wasted work. --no-sysbuild keeps sdk-nrf + # 2.9+ from wrapping the build in a multi-image sysbuild project, which + # would nest the compile commands and hide the headers target. + west_cmd = [ + str(paths["python_executable"]), + "-m", + "west", + "build", + "--no-sysbuild", + "-b", + _TIDY_BOARD, + "-d", + str(build_dir), + str(source_dir), + "-t", + "zephyr_generated_headers", + "--", + "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + ] + if not run_command_ok( + west_cmd, + env=get_build_env(), + stream_output=True, + cwd=str(paths["framework_path"]), + ): + raise EsphomeError("nRF52 clang-tidy configure failed") + + return build_dir / "compile_commands.json" diff --git a/esphome/components/nrf52/dfu.h b/esphome/components/nrf52/dfu.h index 82c7d9f54e..4f7ad89b18 100644 --- a/esphome/components/nrf52/dfu.h +++ b/esphome/components/nrf52/dfu.h @@ -6,7 +6,7 @@ #include "esphome/core/gpio.h" namespace esphome::nrf52 { -class DeviceFirmwareUpdate : public Component { +class DeviceFirmwareUpdate final : public Component { public: void setup() override; void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 607ad0c7ed..fa6f7d57ad 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -1,9 +1,14 @@ +import hashlib import logging import os from pathlib import Path import platform +import shutil import tempfile +import platformdirs + +import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError from esphome.framework_helpers import ( @@ -15,11 +20,12 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) +from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) -_WEST_VERSION = "1.5.0" -_TOOLCHAIN_VERSION = "0.17.4" +_REQUIREMENTS = Path(__file__).parent / "requirements.txt" +TOOLCHAIN_VERSION = "0.17.4" SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( os.environ.get( @@ -28,39 +34,49 @@ 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" + +def get_sdk_nrf_tools_path() -> Path: + # A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("") + # resolves to the CWD, which clean-all would then delete. + if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip(): + path = Path(prefix).expanduser() + else: + # Machine-global (OS user cache dir) so all projects share one install; + # see espidf.framework.get_idf_tools_path for the location rationale. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + return path.resolve() def _get_python_env_path(version: str) -> Path: - return _get_tools_path() / "penvs" / version + return get_sdk_nrf_tools_path() / "penvs" / version def _get_framework_path(version: str) -> Path: - return _get_tools_path() / "frameworks" / f"{version}" + return get_sdk_nrf_tools_path() / "frameworks" / version def _get_toolchain_path(version: str) -> Path: - return _get_tools_path() / "toolchains" / f"{version}" + return get_sdk_nrf_tools_path() / "toolchains" / version -# onexc/dir_fd were added to shutil.rmtree in 3.12; the 3.11 branch uses onerror. _SITECUSTOMIZE = """\ -import os, stat, shutil, sys +import os, stat, shutil _orig = shutil.rmtree def _handler(func, path, exc): os.chmod(path, stat.S_IWRITE); func(path) -if sys.version_info >= (3, 12): - def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): - if onerror is None and onexc is None: - onexc = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) -else: - def _rmtree(path, ignore_errors=False, onerror=None): - if onerror is None: - onerror = _handler - return _orig(path, ignore_errors=ignore_errors, onerror=onerror) +def _rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None): + if onerror is None and onexc is None: + onexc = _handler + return _orig(path, ignore_errors=ignore_errors, onerror=onerror, onexc=onexc, dir_fd=dir_fd) shutil.rmtree = _rmtree """ @@ -95,29 +111,85 @@ 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() + env_path = _get_python_env_path(version) + return { + "python_executable": get_python_env_executable_path(env_path, "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 _patch_uf2conv_escape_sequences(framework_path: Path) -> None: + # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that + # Python 3.12+ flags with SyntaxWarning (a future version will reject it). + uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" + if not uf2conv.exists(): + return + content = uf2conv.read_text(encoding="utf-8") + patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") + if patched == content: + return + # Write atomically so a concurrent build never sees a truncated file + tmp = uf2conv.with_suffix(".py.tmp") + tmp.write_text(patched, encoding="utf-8") + shutil.copymode(uf2conv, tmp) + tmp.replace(uf2conv) + + +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() + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + install_venv = ( + not sentinel.exists() + or sentinel.read_text(encoding="utf-8") != requirements_hash + ) 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") - sentinel.touch() + raise EsphomeError( + f"Install requirements for {version} Python environment failure" + ) + sentinel.write_text(requirements_hash, encoding="utf-8") 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 +200,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): @@ -144,23 +216,41 @@ def check_and_install() -> None: ] if not run_command_ok(cmd, cwd=framework_path): raise EsphomeError(f"Can't update nRF Connect SDK {version}") + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver < cv.Version(2, 9, 2): + _patch_uf2conv_escape_sequences(framework_path) sentinel.touch() - toolchains_dir = _get_toolchain_path(_TOOLCHAIN_VERSION) + 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" - ) + 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 %s toolchain ...", _TOOLCHAIN_VERSION) - - sysname, machine, extension = _get_toolchain_platform_info() - + _LOGGER.info("Downloading Zephyr SDK %s minimal ...", TOOLCHAIN_VERSION) download_from_mirrors( - SDK_NG_TOOLCHAIN_MIRRORS, + SDK_NG_MINIMAL_MIRRORS, { - "VERSION": _TOOLCHAIN_VERSION, + "VERSION": TOOLCHAIN_VERSION, "sysname": sysname, "machine": machine, "extension": extension, @@ -168,4 +258,21 @@ def check_and_install() -> None: tmp.file, ) archive_extract_all(tmp.file, toolchains_dir, progress_header="Extracting") + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s toolchain ...", TOOLCHAIN_VERSION) + download_from_mirrors( + SDK_NG_TOOLCHAIN_MIRRORS, + { + "VERSION": TOOLCHAIN_VERSION, + "sysname": sysname, + "machine": machine, + "extension": extension, + }, + tmp.file, + ) + archive_extract_all( + tmp.file, + toolchains_dir / "arm-zephyr-eabi", + progress_header="Extracting", + ) sentinel.touch() diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index 5d608acbac..cafeda6478 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -5,7 +5,7 @@ import logging from pathlib import Path from bleak import BleakScanner -from bleak.exc import BleakDeviceNotFoundError +from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient from smpclient.generics import error, success @@ -98,6 +98,12 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: await smp_client.connect() except BleakDeviceNotFoundError as exc: raise EsphomeError(f"Device {device} not found") from exc + except BleakDBusError as exc: + if "NotPermitted" in exc.dbus_error: + raise EsphomeError( + f"Cannot connect to {device}: Make sure the device is paired." + ) from exc + raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: raise EsphomeError(f"Connection error with {device}") from exc diff --git a/esphome/components/nrf52/requirements.txt b/esphome/components/nrf52/requirements.txt new file mode 100644 index 0000000000..c55d35b2b1 --- /dev/null +++ b/esphome/components/nrf52/requirements.txt @@ -0,0 +1,4 @@ +west==1.5.0 +ninja==1.13.0 +cmake==4.3.2 +adafruit-nrfutil @ git+https://github.com/adafruit/Adafruit_nRF52_nrfutil.git@7fdfe15feee5f304fb7d9b031721dcefa1f72b58 diff --git a/esphome/components/ntc/ntc.h b/esphome/components/ntc/ntc.h index 466d03f789..25fbf3c85d 100644 --- a/esphome/components/ntc/ntc.h +++ b/esphome/components/ntc/ntc.h @@ -5,7 +5,7 @@ namespace esphome::ntc { -class NTC : public Component, public sensor::Sensor { +class NTC final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_a(double a) { a_ = a; } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index ee2d53c65a..ea0c2d77f6 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -59,6 +59,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -131,6 +132,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -210,9 +212,15 @@ _NUMBER_SCHEMA = ( }, cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW), ), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED + ): cv.enum(NUMBER_MODES, upper=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 2843aa6bf5..4efcfd30d8 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,14 +6,14 @@ namespace esphome::number { -class NumberStateTrigger : public Trigger { +class NumberStateTrigger final : public Trigger { public: explicit NumberStateTrigger(Number *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -template class NumberSetAction : public Action { +template class NumberSetAction final : public Action { public: NumberSetAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(float, value) @@ -28,7 +28,7 @@ template class NumberSetAction : public Action { Number *number_; }; -template class NumberOperationAction : public Action { +template class NumberOperationAction final : public Action { public: explicit NumberOperationAction(Number *number) : number_(number) {} TEMPLATABLE_VALUE(NumberOperation, operation) @@ -47,7 +47,7 @@ template class NumberOperationAction : public Action { Number *number_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} @@ -67,7 +67,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class NumberInRangeCondition : public Condition { +template class NumberInRangeCondition final : public Condition { public: NumberInRangeCondition(Number *parent) : parent_(parent) {} diff --git a/esphome/components/number/sensor/number_sensor.h b/esphome/components/number/sensor/number_sensor.h index 2d6825a298..ba3cec150c 100644 --- a/esphome/components/number/sensor/number_sensor.h +++ b/esphome/components/number/sensor/number_sensor.h @@ -6,7 +6,7 @@ namespace esphome::number { -class NumberSensor : public sensor::Sensor, public Component { +class NumberSensor final : public sensor::Sensor, public Component { public: explicit NumberSensor(Number *source) : source_(source) {} void setup() override; diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index ee4d5abb1c..552a43acad 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -1,150 +1,35 @@ -import logging +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE this whole file +# after 2027.1.0. +# +# Online images are now a platform of the `image:` component (`platform: +# online_image`); the real schema, actions and codegen live in `image.py`. This +# module only keeps the deprecated top-level `online_image:` key working during +# the deprecation window: it reuses that schema/codegen and adds a one-shot +# deprecation warning (with a pasteable migrated `image:` block) at validation +# time. Deleting this file drops the top-level form entirely. +# --------------------------------------------------------------------------- -from esphome import automation -import esphome.codegen as cg -from esphome.components import runtime_image -from esphome.components.const import CONF_REQUEST_HEADERS -from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent -from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.components.image as espImage import esphome.config_validation as cv -from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL -from esphome.core import Lambda + +from .image import ONLINE_IMAGE_CONFIG_SCHEMA, setup_online_image AUTO_LOAD = ["image", "runtime_image"] DEPENDENCIES = ["display", "http_request"] CODEOWNERS = ["@guillempages", "@clydebarrow"] MULTI_CONF = True -CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" -CONF_UPDATE = "update" +DOMAIN = "online_image" -_LOGGER = logging.getLogger(__name__) +LEGACY_REMOVAL_VERSION = "2027.1.0" -online_image_ns = cg.esphome_ns.namespace("online_image") - -OnlineImage = online_image_ns.class_( - "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +_capture_legacy_entry, _warn_legacy_online_image = ( + espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION) ) -# Actions -SetUrlAction = online_image_ns.class_( - "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) -) -ReleaseImageAction = online_image_ns.class_( - "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) -) +CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ONLINE_IMAGE_CONFIG_SCHEMA) +FINAL_VALIDATE_SCHEMA = _warn_legacy_online_image -ONLINE_IMAGE_SCHEMA = ( - runtime_image.runtime_image_schema(OnlineImage) - .extend( - { - # Online Image specific options - cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), - cv.Required(CONF_URL): cv.url, - cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), - cv.Optional(CONF_REQUEST_HEADERS): cv.All( - cv.Schema({cv.string: cv.templatable(cv.string)}) - ), - cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), - cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), - } - ) - .extend(cv.polling_component_schema("never")) -) - -CONFIG_SCHEMA = cv.Schema( - cv.All( - ONLINE_IMAGE_SCHEMA, - cv.require_framework_version( - # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed - # esp8266_arduino=cv.Version(2, 7, 0), - esp32_arduino=cv.Version(0, 0, 0), - esp_idf=cv.Version(4, 0, 0), - rp2040_arduino=cv.Version(0, 0, 0), - host=cv.Version(0, 0, 0), - ), - runtime_image.validate_runtime_image_settings, - ) -) - -SET_URL_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.use_id(OnlineImage), - cv.Required(CONF_URL): cv.templatable(cv.url), - cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), - } -) - -RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(OnlineImage), - } -) - - -@automation.register_action( - "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True -) -@automation.register_action( - "online_image.release", - ReleaseImageAction, - RELEASE_IMAGE_SCHEMA, - synchronous=True, -) -async def online_image_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - if CONF_URL in config: - template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) - cg.add(var.set_url(template_)) - if CONF_UPDATE in config: - template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) - cg.add(var.set_update(template_)) - return var - - -_CALLBACK_AUTOMATIONS = ( - automation.CallbackAutomation( - CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] - ), - automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), -) - - -async def to_code(config): - # Use the enhanced helper function to get all runtime image parameters - settings = await runtime_image.process_runtime_image_config(config) - add_metadata( - config[CONF_ID], - settings.width, - settings.height, - config[CONF_TYPE], - config[CONF_TRANSPARENCY], - ) - - url = config[CONF_URL] - var = cg.new_Pvariable( - config[CONF_ID], - url, - settings.width, - settings.height, - settings.format_enum, - settings.image_type_enum, - settings.transparent, - settings.placeholder or cg.nullptr, - config[CONF_BUFFER_SIZE], - settings.byte_order_big_endian, - ) - await cg.register_component(var, config) - await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) - - for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): - if isinstance(value, Lambda): - template_ = await cg.templatable(value, [], cg.std_string) - cg.add(var.add_request_header(key, template_)) - else: - cg.add(var.add_request_header(key, value)) - - await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +to_code = setup_online_image diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py new file mode 100644 index 0000000000..cb86f93e29 --- /dev/null +++ b/esphome/components/online_image/image.py @@ -0,0 +1,152 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.http_request import CONF_HTTP_REQUEST_ID, HttpRequestComponent +from esphome.components.image import CONF_TRANSPARENCY, add_metadata +import esphome.config_validation as cv +from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_ON_ERROR, CONF_TYPE, CONF_URL +from esphome.core import Lambda +from esphome.types import ConfigType + +AUTO_LOAD = ["runtime_image"] +DEPENDENCIES = ["http_request"] +CODEOWNERS = ["@guillempages", "@clydebarrow"] + +CONF_ON_DOWNLOAD_FINISHED = "on_download_finished" +CONF_UPDATE = "update" + +online_image_ns = cg.esphome_ns.namespace("online_image") + +OnlineImage = online_image_ns.class_( + "OnlineImage", cg.PollingComponent, runtime_image.RuntimeImage +) + +# Actions +SetUrlAction = online_image_ns.class_( + "OnlineImageSetUrlAction", automation.Action, cg.Parented.template(OnlineImage) +) +ReleaseImageAction = online_image_ns.class_( + "OnlineImageReleaseAction", automation.Action, cg.Parented.template(OnlineImage) +) + + +ONLINE_IMAGE_SCHEMA = ( + runtime_image.runtime_image_schema(OnlineImage) + .extend( + { + # Online Image specific options + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.Required(CONF_URL): cv.url, + cv.Optional(CONF_BUFFER_SIZE, default=65536): cv.int_range(256, 65536), + cv.Optional(CONF_REQUEST_HEADERS): cv.All( + cv.Schema({cv.string: cv.templatable(cv.string)}) + ), + cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}), + cv.Optional(CONF_ON_ERROR): automation.validate_automation({}), + } + ) + .extend(cv.polling_component_schema("never")) +) + +# Shared schema used by both the (deprecated) top-level `online_image:` key and +# the `image:` `platform: online_image` entry. +ONLINE_IMAGE_CONFIG_SCHEMA = cv.All( + ONLINE_IMAGE_SCHEMA, + cv.require_framework_version( + # esp8266 not supported yet; if enabled in the future, minimum version of 2.7.0 is needed + # esp8266_arduino=cv.Version(2, 7, 0), + esp32_arduino=cv.Version(0, 0, 0), + esp_idf=cv.Version(4, 0, 0), + rp2_arduino=cv.Version(0, 0, 0), + host=cv.Version(0, 0, 0), + ), + runtime_image.validate_runtime_image_settings, +) + + +SET_URL_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(OnlineImage), + cv.Required(CONF_URL): cv.templatable(cv.url), + cv.Optional(CONF_UPDATE, default=True): cv.templatable(bool), + } +) + +RELEASE_IMAGE_SCHEMA = automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(OnlineImage), + } +) + + +@automation.register_action( + "online_image.set_url", SetUrlAction, SET_URL_SCHEMA, synchronous=True +) +@automation.register_action( + "online_image.release", + ReleaseImageAction, + RELEASE_IMAGE_SCHEMA, + synchronous=True, +) +async def online_image_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + if CONF_URL in config: + template_ = await cg.templatable(config[CONF_URL], args, cg.std_string) + cg.add(var.set_url(template_)) + if CONF_UPDATE in config: + template_ = await cg.templatable(config[CONF_UPDATE], args, cg.bool_) + cg.add(var.set_update(template_)) + return var + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")] + ), + automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"), +) + + +async def setup_online_image(config: ConfigType) -> None: + # Use the enhanced helper function to get all runtime image parameters + settings = await runtime_image.process_runtime_image_config(config) + add_metadata( + config[CONF_ID], + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + + url = config[CONF_URL] + var = cg.new_Pvariable( + config[CONF_ID], + url, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.placeholder or cg.nullptr, + config[CONF_BUFFER_SIZE], + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_HTTP_REQUEST_ID]) + + for key, value in config.get(CONF_REQUEST_HEADERS, {}).items(): + if isinstance(value, Lambda): + template_ = await cg.templatable(value, [], cg.std_string) + cg.add(var.add_request_header(key, template_)) + else: + cg.add(var.add_request_header(key, value)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +CONFIG_SCHEMA = ONLINE_IMAGE_CONFIG_SCHEMA + +to_code = setup_online_image 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/online_image/online_image.h b/esphome/components/online_image/online_image.h index a967bb6c0e..3e386f8cc8 100644 --- a/esphome/components/online_image/online_image.h +++ b/esphome/components/online_image/online_image.h @@ -21,9 +21,9 @@ using t_http_codes = enum { * The image will then be stored in a buffer, so that it can be re-displayed without the * need to re-download or re-decode. */ -class OnlineImage : public PollingComponent, - public runtime_image::RuntimeImage, - public Parented { +class OnlineImage final : public PollingComponent, + public runtime_image::RuntimeImage, + public Parented { public: /** * @brief Construct a new OnlineImage object. @@ -104,7 +104,7 @@ class OnlineImage : public PollingComponent, uint32_t start_time_{0}; }; -template class OnlineImageSetUrlAction : public Action { +template class OnlineImageSetUrlAction final : public Action { public: OnlineImageSetUrlAction(OnlineImage *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, url) @@ -120,7 +120,7 @@ template class OnlineImageSetUrlAction : public Action { OnlineImage *parent_; }; -template class OnlineImageReleaseAction : public Action { +template class OnlineImageReleaseAction final : public Action { public: OnlineImageReleaseAction(OnlineImage *parent) : parent_(parent) {} void play(const Ts &...x) override { this->parent_->release(); } diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h index aa20a4ec5a..365992b280 100644 --- a/esphome/components/opentherm/automation.h +++ b/esphome/components/opentherm/automation.h @@ -6,14 +6,14 @@ namespace esphome::opentherm { -class BeforeSendTrigger : public Trigger { +class BeforeSendTrigger final : public Trigger { public: BeforeSendTrigger(OpenthermHub *hub) { hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); } }; -class BeforeProcessResponseTrigger : public Trigger { +class BeforeProcessResponseTrigger final : public Trigger { public: BeforeProcessResponseTrigger(OpenthermHub *hub) { hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); diff --git a/esphome/components/opentherm/hub.h b/esphome/components/opentherm/hub.h index 2638137668..268c6210f0 100644 --- a/esphome/components/opentherm/hub.h +++ b/esphome/components/opentherm/hub.h @@ -41,7 +41,7 @@ static const uint8_t REPEATING_MESSAGE_ORDER = 255; static const uint8_t INITIAL_UNORDERED_MESSAGE_ORDER = 254; // OpenTherm component for ESPHome -class OpenthermHub : public Component { +class OpenthermHub final : public Component { protected: // Communication pins for the OpenTherm interface InternalGPIOPin *in_pin_, *out_pin_; diff --git a/esphome/components/opentherm/number/opentherm_number.h b/esphome/components/opentherm/number/opentherm_number.h index c110bed2eb..c97692ce2a 100644 --- a/esphome/components/opentherm/number/opentherm_number.h +++ b/esphome/components/opentherm/number/opentherm_number.h @@ -8,7 +8,7 @@ namespace esphome::opentherm { // Just a simple number, which stores the number -class OpenthermNumber : public number::Number, public Component, public OpenthermInput { +class OpenthermNumber final : public number::Number, public Component, public OpenthermInput { protected: void control(float value) override; void setup() override; diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 1ee4c9191b..5cf7c19880 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -541,7 +541,7 @@ void OpenTherm::debug_error(OpenThermError &error) const { error.capture, error.bit_pos); } -float OpenthermData::f88() { return ((float) this->s16()) / 256.0; } +float OpenthermData::f88() { return ((float) this->s16()) / 256.0f; } void OpenthermData::f88(float value) { this->s16((int16_t) (value * 256)); } diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 4092358d75..9b87cd8d12 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -12,8 +12,9 @@ void opentherm::OpenthermOutput::write_state(float state) { #else bool zero_means_zero = false; #endif - this->state = - state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); + this->state = state < 0.003f && zero_means_zero + ? 0.0f + : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_); this->has_state_ = true; ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state); } diff --git a/esphome/components/opentherm/output/opentherm_output.h b/esphome/components/opentherm/output/opentherm_output.h index e789d72702..24d5052076 100644 --- a/esphome/components/opentherm/output/opentherm_output.h +++ b/esphome/components/opentherm/output/opentherm_output.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermOutput : public output::FloatOutput, public Component, public OpenthermInput { +class OpenthermOutput final : public output::FloatOutput, public Component, public OpenthermInput { protected: bool has_state_ = false; const char *id_ = nullptr; diff --git a/esphome/components/opentherm/switch/opentherm_switch.h b/esphome/components/opentherm/switch/opentherm_switch.h index ca930d4f7c..235bc23401 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.h +++ b/esphome/components/opentherm/switch/opentherm_switch.h @@ -6,7 +6,7 @@ namespace esphome::opentherm { -class OpenthermSwitch : public switch_::Switch, public Component { +class OpenthermSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index bc1e91d6da..4018ad81e7 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,8 +1,12 @@ +from esphome import automation import esphome.codegen as cg from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, include_builtin_idf_component, @@ -10,6 +14,9 @@ from esphome.components.esp32 import ( require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage +from esphome.components.network import add_use_address +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 +27,7 @@ from esphome.const import ( CONF_OUTPUT_POWER, CONF_USE_ADDRESS, PLATFORM_ESP32, + PlatformFramework, ) from esphome.core import ( CORE, @@ -52,7 +60,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 +105,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 +133,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 +161,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 +182,40 @@ 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, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, + ] + )(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,17 +226,17 @@ 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( cv.decibel, _validate_txpower, ), + cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, } ).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,19 +263,33 @@ 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() ot = cg.new_Pvariable(config[CONF_ID]) - cg.add(ot.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(ot, config[CONF_USE_ADDRESS]) await cg.register_component(ot, config) if (poll_period := config.get(CONF_POLL_PERIOD)) is not None: cg.add(ot.set_poll_period(poll_period)) @@ -252,4 +302,46 @@ 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) + + +# Actions +OpenThreadComponentPollPeriodAction = openthread_ns.class_( + "OpenThreadComponentPollPeriodAction", + automation.Action, + cg.Parented.template(OpenThreadComponent), +) + +POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( + CONF_POLL_PERIOD, + cv.Schema( + { + cv.GenerateID(): cv.use_id(OpenThreadComponent), + cv.Required(CONF_POLL_PERIOD): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), +) + + +@automation.register_action( + "openthread.set_poll_period", + OpenThreadComponentPollPeriodAction, + POLL_PERIOD_ACTION_SCHEMA, + synchronous=True, +) +async def openthread_poll_period_action_to_code(config, action_id, template_arg, args): + paren = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = await cg.templatable(config[CONF_POLL_PERIOD], args, cg.uint32) + cg.add(var.set_poll_period(template_)) + return var diff --git a/esphome/components/openthread/automation.cpp b/esphome/components/openthread/automation.cpp new file mode 100644 index 0000000000..770bf124c5 --- /dev/null +++ b/esphome/components/openthread/automation.cpp @@ -0,0 +1,37 @@ +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD + +#include "automation.h" +#include "esphome/core/log.h" + +namespace esphome::openthread { + +static const char *const TAG = "openthread.automation"; + +void OpenThreadComponentBaseAction::warn_ftd_no_op_() { + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +} + +void OpenThreadComponentBaseAction::lock_and_apply_() { + if (this->parent_->is_ready()) { + if (auto lock = InstanceLock::try_acquire(LOCK_ACQUIRE_TIMEOUT_MS); lock) { + if (auto *instance = lock.get_instance(); instance != nullptr) { + this->apply_locked(instance); + } + } else { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + } + } else { + // Action may trigger early before setup, e.g. due to enabled "restore mode". + // Trying to acquire lock would fail! + // + // But default component values already have been overwritten. + // It is sufficient to let component apply those later during setup. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + } +} + +} // namespace esphome::openthread + +#endif diff --git a/esphome/components/openthread/automation.h b/esphome/components/openthread/automation.h new file mode 100644 index 0000000000..3706499fda --- /dev/null +++ b/esphome/components/openthread/automation.h @@ -0,0 +1,61 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_OPENTHREAD +#include "openthread.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::openthread { + +/** Base class allowing to fetch OpenThread lock from parent component + * while applying action + * + * - Nontemplate aspects belong here to avoid template bloat. + * - Subclasses implement virtual action method that is called under lock. + * - Seal leaf subclasses via @a final to support devirtualization. + */ +class OpenThreadComponentBaseAction : public Parented { + public: + // Enforce ctor with parent argument (not without args) + explicit OpenThreadComponentBaseAction(OpenThreadComponent *ot) : Parented(ot) {} + + protected: + /** Handler to implement in subclass for applying action parts that need lock */ + virtual void apply_locked(otInstance *instance) = 0; + + /** Fetch OT lock and then call @a apply_locked */ + void lock_and_apply_(); + + /** Log a warning that this action has no effect on FTD devices */ + void warn_ftd_no_op_(); + + /** Timeout (ms) for acquiring OT lock */ + static constexpr uint32_t LOCK_ACQUIRE_TIMEOUT_MS = 100; +}; + +/** Action to set single poll period parameter */ +template +class OpenThreadComponentPollPeriodAction final : public Action, public OpenThreadComponentBaseAction { + TEMPLATABLE_VALUE(uint32_t, poll_period) + + public: + /* Passthrough ctor */ + using OpenThreadComponentBaseAction::OpenThreadComponentBaseAction; + + protected: + void play(const Ts &...x) override { +#if CONFIG_OPENTHREAD_MTD + this->parent_->set_poll_period(this->poll_period_.value(x...)); + + this->lock_and_apply_(); +#else + this->warn_ftd_no_op_(); +#endif + } + + void apply_locked(otInstance *instance) override { this->parent_->apply_linkmode_(instance); } +}; + +} // namespace esphome::openthread +#endif diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index c8ffc02131..8bfc16b2e0 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(); @@ -233,7 +243,7 @@ bool OpenThreadComponent::teardown() { 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; } @@ -256,5 +266,34 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_linkmode_(otInstance *instance) { + otLinkModeConfig link_mode_config{}; +#if CONFIG_OPENTHREAD_FTD + link_mode_config.mRxOnWhenIdle = true; + link_mode_config.mDeviceType = true; + link_mode_config.mNetworkData = true; +#elif CONFIG_OPENTHREAD_MTD + if (this->poll_period_ > 0) { + if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set pollperiod"); + } + ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); + } + link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; + link_mode_config.mDeviceType = false; + link_mode_config.mNetworkData = false; +#endif + + if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set linkmode"); + } +#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG + link_mode_config = otThreadGetLinkMode(instance); + ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", + TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), + TRUEFALSE(link_mode_config.mRxOnWhenIdle)); +#endif +} + } // namespace esphome::openthread #endif diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index 96f1abdb92..b4654af21f 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -19,7 +19,9 @@ namespace esphome::openthread { class InstanceLock; -class OpenThreadComponent : public Component { +template class OpenThreadComponentPollPeriodAction; + +class OpenThreadComponent final : public Component { public: OpenThreadComponent(); ~OpenThreadComponent(); @@ -37,16 +39,30 @@ class OpenThreadComponent : public Component { void on_factory_reset(std::function callback); void defer_factory_reset_external_callback(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } #if CONFIG_OPENTHREAD_MTD void set_poll_period(uint32_t poll_period) { this->poll_period_ = poll_period; } + uint32_t get_poll_period() const { return this->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: + // Actions re-apply link mode under the OT lock; allow them to call apply_linkmode_() + // without exposing this lock-sensitive, raw-instance method on the public API. + template friend class OpenThreadComponentPollPeriodAction; + + /** Apply Link Mode settings (incl poll period). + * Callers running outside the OpenThread task must hold InstanceLock. + * ot_main() runs on the OpenThread task itself and must not acquire the lock. + */ + void apply_linkmode_(otInstance *instance); + 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_; @@ -62,16 +78,16 @@ class OpenThreadComponent : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class OpenThreadSrpComponent : public Component { +class OpenThreadSrpComponent final : public Component { public: void set_mdns(esphome::mdns::MDNSComponent *mdns); // This has to run after the mdns component or else no services are available to advertise - float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } + float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, const otSrpClientService *removed_services, void *context); diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 4d88cbd226..4f6e618f49 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -111,32 +111,7 @@ void OpenThreadComponent::ot_main() { ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - otLinkModeConfig link_mode_config{}; -#if CONFIG_OPENTHREAD_FTD - link_mode_config.mRxOnWhenIdle = true; - link_mode_config.mDeviceType = true; - link_mode_config.mNetworkData = true; -#elif CONFIG_OPENTHREAD_MTD - if (this->poll_period_ > 0) { - if (otLinkSetPollPeriod(instance, this->poll_period_) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set pollperiod"); - } - ESP_LOGD(TAG, "Link Polling Period: %" PRIu32, otLinkGetPollPeriod(instance)); - } - link_mode_config.mRxOnWhenIdle = this->poll_period_ == 0; - link_mode_config.mDeviceType = false; - link_mode_config.mNetworkData = false; -#endif - - if (otThreadSetLinkMode(instance, link_mode_config) != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set linkmode"); - } -#ifdef ESPHOME_LOG_HAS_DEBUG // Fetch link mode from OT only when DEBUG - link_mode_config = otThreadGetLinkMode(instance); - ESP_LOGD(TAG, "Link Mode Device Type: %s, Network Data: %s, RX On When Idle: %s", - TRUEFALSE(link_mode_config.mDeviceType), TRUEFALSE(link_mode_config.mNetworkData), - TRUEFALSE(link_mode_config.mRxOnWhenIdle)); -#endif + this->apply_linkmode_(instance); if (this->output_power_.has_value()) { if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { @@ -217,7 +192,7 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() { otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); } InstanceLock InstanceLock::try_acquire(int delay) { - if (!global_openthread_component->is_lock_initialized()) { + if (global_openthread_component == nullptr || !global_openthread_component->is_lock_initialized()) { return InstanceLock(false); } return InstanceLock(esp_openthread_lock_acquire(delay)); 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/opt3001/opt3001.h b/esphome/components/opt3001/opt3001.h index e5de536353..92f5136bf7 100644 --- a/esphome/components/opt3001/opt3001.h +++ b/esphome/components/opt3001/opt3001.h @@ -7,7 +7,7 @@ namespace esphome::opt3001 { /// This class implements support for the i2c-based OPT3001 ambient light sensor. -class OPT3001Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class OPT3001Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void dump_config() override; void update() override; diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 83d8c611d5..8296410f2f 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -99,7 +99,7 @@ async def to_code(config): cg.add_define("USE_OTA") CORE.add_job(final_step) - if CORE.is_rp2040 and CORE.using_arduino: + if CORE.is_rp2 and CORE.using_arduino: cg.add_library("Updater", None) @@ -158,7 +158,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "ota_backend_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "ota_backend_arduino_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 17949de642..0447b968a3 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -2,6 +2,34 @@ namespace esphome::ota { +bool version_is_older(const char *candidate, const char *reference) { + if (candidate == nullptr || reference == nullptr) + return false; + while (true) { + uint32_t a = 0; + while (*candidate >= '0' && *candidate <= '9') { + a = a * 10 + static_cast(*candidate - '0'); + candidate++; + } + uint32_t b = 0; + while (*reference >= '0' && *reference <= '9') { + b = b * 10 + static_cast(*reference - '0'); + reference++; + } + if (a != b) + return a < b; + // Components equal so far; advance past a single separator on each side. + const bool a_more = (*candidate == '.'); + const bool b_more = (*reference == '.'); + if (a_more) + candidate++; + if (b_more) + reference++; + if (!a_more && !b_more) + return false; // Both strings exhausted with all components equal. + } +} + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index de236c1951..01be46a518 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -46,9 +46,24 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90, OTA_RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91, OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, + OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; +/** Compare two dotted-numeric version strings (such as "1.2.3"). + * + * Returns true when @p candidate represents a strictly older (lower) firmware + * version than @p reference. Each dot-separated component is parsed as an + * integer and compared left-to-right; absent trailing components count as 0, + * so "1.2" and "1.2.0" are equal. Equal versions return false so that + * re-flashing the same version is permitted. + * + * Used for software OTA downgrade protection. Inputs come from the project + * version embedded in the signed firmware image, which is validated to be + * dotted-numeric at config time. Non-digit characters terminate a component. + */ +bool version_is_older(const char *candidate, const char *reference); + enum OTAState { OTA_COMPLETED = 0, OTA_STARTED, diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp similarity index 70% rename from esphome/components/ota/ota_backend_arduino_rp2040.cpp rename to esphome/components/ota/ota_backend_arduino_rp2.cpp index 0ca0602519..b35eb38c12 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -1,9 +1,9 @@ #ifdef USE_ARDUINO -#ifdef USE_RP2040 -#include "ota_backend_arduino_rp2040.h" +#ifdef USE_RP2 +#include "ota_backend_arduino_rp2.h" #include "ota_backend.h" -#include "esphome/components/rp2040/preferences.h" +#include "esphome/components/rp2/preferences.h" #include "esphome/core/defines.h" #include "esphome/core/log.h" @@ -11,11 +11,11 @@ namespace esphome::ota { -static const char *const TAG = "ota.arduino_rp2040"; +static const char *const TAG = "ota.arduino_rp2"; -std::unique_ptr make_ota_backend() { return make_unique(); } +std::unique_ptr make_ota_backend() { return make_unique(); } -OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) { +OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -23,7 +23,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t // web_server is not supported for RP2040, so this is not an issue. bool ret = Update.begin(image_size, U_FLASH); if (ret) { - rp2040::preferences_prevent_write(true); + rp2::preferences_prevent_write(true); return OTA_RESPONSE_OK; } @@ -42,12 +42,12 @@ OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_t return OTA_RESPONSE_ERROR_UNKNOWN; } -void ArduinoRP2040OTABackend::set_update_md5(const char *md5) { +void ArduinoRP2OTABackend::set_update_md5(const char *md5) { Update.setMD5(md5); this->md5_set_ = true; } -OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { +OTAResponseTypes ArduinoRP2OTABackend::write(uint8_t *data, size_t len) { size_t written = Update.write(data, len); if (written == len) { return OTA_RESPONSE_OK; @@ -59,7 +59,7 @@ OTAResponseTypes ArduinoRP2040OTABackend::write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_WRITING_FLASH; } -OTAResponseTypes ArduinoRP2040OTABackend::end() { +OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { @@ -72,11 +72,11 @@ OTAResponseTypes ArduinoRP2040OTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoRP2040OTABackend::abort() { +void ArduinoRP2OTABackend::abort() { Update.end(); - rp2040::preferences_prevent_write(false); + rp2::preferences_prevent_write(false); } } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2.h similarity index 78% rename from esphome/components/ota/ota_backend_arduino_rp2040.h rename to esphome/components/ota/ota_backend_arduino_rp2.h index d04d5c1a84..f7c0037bd2 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -1,6 +1,6 @@ #pragma once #ifdef USE_ARDUINO -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "ota_backend.h" #include "esphome/core/defines.h" @@ -8,7 +8,7 @@ namespace esphome::ota { -class ArduinoRP2040OTABackend final { +class ArduinoRP2OTABackend final { public: OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP); void set_update_md5(const char *md5); @@ -21,8 +21,8 @@ class ArduinoRP2040OTABackend final { bool md5_set_{false}; }; -std::unique_ptr make_ota_backend(); +std::unique_ptr make_ota_backend(); } // namespace esphome::ota -#endif // USE_RP2040 +#endif // USE_RP2 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index ac765d8018..8fd21f42bd 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -9,6 +9,9 @@ #include #include #include +#ifdef USE_OTA_DOWNGRADE_PROTECTION +#include +#endif namespace esphome::ota { @@ -159,6 +162,23 @@ OTAResponseTypes IDFOTABackend::end() { } #endif if (err == ESP_OK) { +#ifdef USE_OTA_DOWNGRADE_PROTECTION + // The image is written and (when signing is enabled) signature-verified by + // esp_ota_end(), so its embedded project version can be trusted. Reject the + // update if it is older than the running version by leaving the boot + // partition unchanged -- the staged image simply never boots. + esp_app_desc_t incoming; + esp_err_t desc_err = esp_ota_get_partition_description(this->partition_, &incoming); + if (desc_err != ESP_OK) { + // Couldn't read the staged image's version, so the comparison is skipped. + // Warn so the bypassed check is observable rather than silent. + ESP_LOGW(TAG, "Downgrade protection: could not read image version (err=0x%X); allowing update", desc_err); + } else if (version_is_older(incoming.version, ESPHOME_PROJECT_VERSION)) { + ESP_LOGE(TAG, "Rejecting downgrade: image version '%s' is older than running version '%s'", incoming.version, + ESPHOME_PROJECT_VERSION); + return OTA_RESPONSE_ERROR_VERSION_DOWNGRADE; + } +#endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { return OTA_RESPONSE_OK; diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index 7c79f02702..c543983d8d 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -8,8 +8,8 @@ #include "ota_backend_esp8266.h" #elif defined(USE_ESP32) #include "ota_backend_esp_idf.h" -#elif defined(USE_RP2040) -#include "ota_backend_arduino_rp2040.h" +#elif defined(USE_RP2) +#include "ota_backend_arduino_rp2.h" #elif defined(USE_LIBRETINY) #include "ota_backend_arduino_libretiny.h" #elif defined(USE_HOST) diff --git a/esphome/components/output/automation.h b/esphome/components/output/automation.h index 301f568388..efe775ba57 100644 --- a/esphome/components/output/automation.h +++ b/esphome/components/output/automation.h @@ -8,7 +8,7 @@ namespace esphome::output { -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: TurnOffAction(BinaryOutput *output) : output_(output) {} @@ -18,7 +18,7 @@ template class TurnOffAction : public Action { BinaryOutput *output_; }; -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: TurnOnAction(BinaryOutput *output) : output_(output) {} @@ -28,7 +28,7 @@ template class TurnOnAction : public Action { BinaryOutput *output_; }; -template class SetLevelAction : public Action { +template class SetLevelAction final : public Action { public: SetLevelAction(FloatOutput *output) : output_(output) {} @@ -41,7 +41,7 @@ template class SetLevelAction : public Action { }; #ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction : public Action { +template class SetMinPowerAction final : public Action { public: SetMinPowerAction(FloatOutput *output) : output_(output) {} @@ -53,7 +53,7 @@ template class SetMinPowerAction : public Action { FloatOutput *output_; }; -template class SetMaxPowerAction : public Action { +template class SetMaxPowerAction final : public Action { public: SetMaxPowerAction(FloatOutput *output) : output_(output) {} diff --git a/esphome/components/output/button/output_button.h b/esphome/components/output/button/output_button.h index 1a2997bdcf..bf6be8afe1 100644 --- a/esphome/components/output/button/output_button.h +++ b/esphome/components/output/button/output_button.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputButton : public button::Button, public Component { +class OutputButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/output/lock/output_lock.h b/esphome/components/output/lock/output_lock.h index 7be96e1e82..8e5f4ff7df 100644 --- a/esphome/components/output/lock/output_lock.h +++ b/esphome/components/output/lock/output_lock.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputLock : public lock::Lock, public Component { +class OutputLock final : public lock::Lock, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/output/switch/output_switch.h b/esphome/components/output/switch/output_switch.h index b0d85678be..878104f14c 100644 --- a/esphome/components/output/switch/output_switch.h +++ b/esphome/components/output/switch/output_switch.h @@ -6,7 +6,7 @@ namespace esphome::output { -class OutputSwitch : public switch_::Switch, public Component { +class OutputSwitch final : public switch_::Switch, public Component { public: void set_output(BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index c1c5bd2ae3..44a1ebf36e 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,7 +1,6 @@ from collections import UserDict from collections.abc import Callable from functools import reduce -import logging from pathlib import Path from typing import Any @@ -36,8 +35,6 @@ from esphome.const import ( ) from esphome.core import EsphomeError -_LOGGER = logging.getLogger(__name__) - DOMAIN = CONF_PACKAGES # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -53,18 +50,6 @@ def is_remote_package(package_config: dict) -> bool: return CONF_URL in package_config -def is_package_definition(value: object) -> bool: - """Returns True if the value looks like a package definition rather than a config fragment. - - Package definitions are IncludeFile objects, git URL shorthand strings, or - remote package dicts (containing a ``url:`` key). Config fragments are - plain dicts that represent component configuration. - """ - return isinstance(value, (yaml_util.IncludeFile, str)) or ( - isinstance(value, dict) and is_remote_package(value) - ) - - def valid_package_contents(package_config: dict) -> dict: """Validate that a package looks like a plausible ESPHome config fragment. @@ -134,22 +119,6 @@ def validate_source_shorthand(value): return REMOTE_PACKAGE_SCHEMA(conf) -def deprecate_single_package(config: dict) -> dict: - _LOGGER.warning( - """ - Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. - This method for including packages will go away in 2026.7.0 - Please use a list instead: - - packages: - - !include mypackage.yaml - - See https://github.com/esphome/esphome/pull/12116 - """ - ) - return config - - REMOTE_PACKAGE_SCHEMA = cv.All( cv.Schema( { @@ -198,10 +167,7 @@ CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: str: PACKAGE_SCHEMA, # a named dict of package definitions, or } ), - [PACKAGE_SCHEMA], # a list of package definitions, or - cv.All( # a single package definition (deprecated) - cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package - ), + [PACKAGE_SCHEMA], # a list of package definitions ) @@ -348,7 +314,6 @@ def _walk_packages( config: dict, callback: PackageCallback, context: ContextVars | None = None, - validate_deprecated: bool = True, path: yaml_util.DocumentPath | None = None, ) -> dict: """Walks the packages structure in priority order, invoking ``callback`` on each package definition found. @@ -378,17 +343,7 @@ def _walk_packages( elif ( result := _walk_package_dict(packages, callback, context, packages_path) ) is not None: - if not validate_deprecated or any( - is_package_definition(v) for v in packages.values() - ): - raise result - # Fallback: treat the dict as a single deprecated package. - # This block can be removed once the single-package - # deprecation period (2026.7.0) is over. - config[CONF_PACKAGES] = [packages] - return _walk_packages( - deprecate_single_package(config), callback, context, path=path - ) + raise result config[CONF_PACKAGES] = packages return config @@ -588,9 +543,6 @@ class _PackageProcessor: path: yaml_util.DocumentPath, ) -> dict: """Resolve a single package and recurse into any nested packages.""" - from_remote = isinstance(package_config, dict) and is_remote_package( - package_config - ) package_config = self.resolve_package(package_config, context_vars, path) context_vars = self.collect_substitutions(package_config, context_vars) @@ -600,17 +552,10 @@ class _PackageProcessor: # Push context from !include vars on the packages key (the package root # was already pushed in collect_substitutions above). context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - # Disable the deprecated single-package fallback for remote - # packages. _process_remote_package returns dicts with - # already-resolved values that is_package_definition cannot - # distinguish from config fragments, so the fallback would - # always fire and mask real errors with wrong paths - # (packages->0 instead of packages->). return _walk_packages( package_config, self.process_package, context_vars, - validate_deprecated=not from_remote, path=path, ) @@ -673,7 +618,7 @@ def merge_packages(config: dict) -> dict: merge_list.append(package_config) return _walk_packages(package_config, process_package_callback, path=path) - _walk_packages(config, process_package_callback, validate_deprecated=False) + _walk_packages(config, process_package_callback) # Merge all packages into the main config: config = reduce(lambda new, old: merge_config(old, new), merge_list, config) del config[CONF_PACKAGES] diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index 7a2f3678c1..adadde068c 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -31,7 +31,7 @@ class AddressableSegment { bool reversed_; }; -class PartitionLightOutput : public light::AddressableLight { +class PartitionLightOutput final : public light::AddressableLight { public: explicit PartitionLightOutput(std::vector segments) : segments_(std::move(segments)) { int32_t off = 0; diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 3170033b28..39011d53ab 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -7,9 +7,9 @@ namespace esphome::pca6416a { -class PCA6416AComponent : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA6416AComponent final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA6416AComponent() = default; @@ -49,7 +49,7 @@ class PCA6416AComponent : public Component, }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. -class PCA6416AGPIOPin : public GPIOPin { +class PCA6416AGPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 9fa398cf29..05e945d176 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -7,9 +7,9 @@ namespace esphome::pca9554 { -class PCA9554Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCA9554Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCA9554Component() = default; @@ -53,7 +53,7 @@ class PCA9554Component : public Component, }; /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. -class PCA9554GPIOPin : public GPIOPin { +class PCA9554GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 33819f23ee..dad722888f 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -24,7 +24,7 @@ inline constexpr uint8_t PCA9685_MODE_OUTNE_LOW = 0x01; class PCA9685Output; -class PCA9685Channel : public output::FloatOutput { +class PCA9685Channel final : public output::FloatOutput { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(PCA9685Output *parent) { parent_ = parent; } @@ -39,7 +39,7 @@ class PCA9685Channel : public output::FloatOutput { }; /// PCA9685 float output component. -class PCA9685Output : public Component, public i2c::I2CDevice { +class PCA9685Output final : public Component, public i2c::I2CDevice { public: PCA9685Output(uint8_t mode = PCA9685_MODE_OUTPUT_ONACK | PCA9685_MODE_OUTPUT_TOTEM_POLE) : mode_(mode) {} diff --git a/esphome/components/pcd8544/pcd_8544.h b/esphome/components/pcd8544/pcd_8544.h index 9e4ee93035..3368c39551 100644 --- a/esphome/components/pcd8544/pcd_8544.h +++ b/esphome/components/pcd8544/pcd_8544.h @@ -6,9 +6,9 @@ namespace esphome::pcd8544 { -class PCD8544 : public display::DisplayBuffer, - public spi::SPIDevice { +class PCD8544 final : public display::DisplayBuffer, + public spi::SPIDevice { public: const uint8_t PCD8544_POWERDOWN = 0x04; const uint8_t PCD8544_ENTRYMODE = 0x02; diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 1c6b6bf36d..659260ba5e 100644 --- a/esphome/components/pcf85063/pcf85063.h +++ b/esphome/components/pcf85063/pcf85063.h @@ -6,7 +6,7 @@ namespace esphome::pcf85063 { -class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF85063Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -81,12 +81,12 @@ class PCF85063Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf85063_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8563/pcf8563.h b/esphome/components/pcf8563/pcf8563.h index 72b600d9ba..e208774c2c 100644 --- a/esphome/components/pcf8563/pcf8563.h +++ b/esphome/components/pcf8563/pcf8563.h @@ -6,7 +6,7 @@ namespace esphome::pcf8563 { -class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { +class PCF8563Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -109,12 +109,12 @@ class PCF8563Component : public time::RealTimeClock, public i2c::I2CDevice { } pcf8563_; }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index ece472c4bb..e8f78bae50 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -9,9 +9,9 @@ namespace esphome::pcf8574 { // PCF8574(8 pins)/PCF8575(16 pins) always read/write all pins in a single I2C transaction // so we use uint16_t as bank type to ensure all pins are in one bank and cached together -class PCF8574Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PCF8574Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PCF8574Component() = default; @@ -49,7 +49,7 @@ class PCF8574Component : public Component, }; /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. -class PCF8574GPIOPin : public GPIOPin { +class PCF8574GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pcm5122/audio_dac.py b/esphome/components/pcm5122/audio_dac.py index 0017a1ef5a..c18fb3993e 100644 --- a/esphome/components/pcm5122/audio_dac.py +++ b/esphome/components/pcm5122/audio_dac.py @@ -5,6 +5,7 @@ from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, + CONF_ENABLE_PIN, CONF_ID, CONF_INPUT, CONF_INVERTED, @@ -16,6 +17,11 @@ from esphome.const import ( CODEOWNERS = ["@remcom"] DEPENDENCIES = ["i2c"] +CONF_ANALOG_GAIN = "analog_gain" +CONF_CHANNEL_MIX = "channel_mix" +CONF_VOLUME_MIN_DB = "volume_min_db" +CONF_VOLUME_MAX_DB = "volume_max_db" + pcm5122_ns = cg.esphome_ns.namespace("pcm5122") PCM5122 = pcm5122_ns.class_("PCM5122", AudioDac, cg.Component, i2c.I2CDevice) CONF_PCM5122 = "pcm5122" @@ -27,26 +33,60 @@ PCM5122_BITS_PER_SAMPLE_ENUM = { 32: pcm5122_bits_per_sample.PCM5122_BITS_PER_SAMPLE_32, } +pcm5122_analog_gain = pcm5122_ns.enum("PCM5122AnalogGain") +PCM5122_ANALOG_GAIN_ENUM = { + "0db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_0DB, + "-6db": pcm5122_analog_gain.PCM5122_ANALOG_GAIN_MINUS_6DB, +} + +pcm5122_channel_mix = pcm5122_ns.enum("PCM5122ChannelMix") +PCM5122_CHANNEL_MIX_ENUM = { + "stereo": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_STEREO, + "left": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_LEFT_ONLY, + "right": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_RIGHT_ONLY, + "swapped": pcm5122_channel_mix.PCM5122_CHANNEL_MIX_SWAPPED, +} + _validate_bits = cv.float_with_unit("bits", "bit") +def _validate_volume_range(config): + if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: + raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") + return config + + PCM5122GPIOPin = pcm5122_ns.class_( "PCM5122GPIOPin", cg.GPIOPin, cg.Parented.template(PCM5122), ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(PCM5122), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(PCM5122_BITS_PER_SAMPLE_ENUM) ), + cv.Optional(CONF_ANALOG_GAIN, default="0db"): cv.enum( + PCM5122_ANALOG_GAIN_ENUM, lower=True + ), + cv.Optional(CONF_CHANNEL_MIX, default="stereo"): cv.enum( + PCM5122_CHANNEL_MIX_ENUM, lower=True + ), + cv.Optional(CONF_VOLUME_MIN_DB, default="-52.5dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_VOLUME_MAX_DB, default="0dB"): cv.All( + cv.decibel, cv.float_range(min=-103.0, max=24.0) + ), + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x4D)) + .extend(i2c.i2c_device_schema(0x4D)), + _validate_volume_range, ) @@ -96,3 +136,10 @@ async def to_code(config): await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) + cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) + cg.add(var.set_channel_mix(config[CONF_CHANNEL_MIX])) + cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) + cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) + if enable_pin_config := config.get(CONF_ENABLE_PIN): + enable_pin = await cg.gpio_pin_expression(enable_pin_config) + cg.add(var.set_enable_pin(enable_pin)) diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp index 68bbd50e4f..d178cb83b8 100644 --- a/esphome/components/pcm5122/pcm5122.cpp +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -10,6 +10,12 @@ namespace esphome::pcm5122 { static const char *const TAG = "pcm5122"; void PCM5122::setup() { + // Hold XSMT low (soft mute asserted) until init completes + if (this->enable_pin_ != nullptr) { + this->enable_pin_->setup(); + this->enable_pin_->digital_write(false); + } + // Select page 0 and verify chip presence via I2C ACK if (!this->select_page_(0)) { ESP_LOGE(TAG, "Write failed"); @@ -51,7 +57,22 @@ void PCM5122::setup() { } this->reg(PCM5122_REG_AUDIO_FORMAT) = PCM5122_AUDIO_FORMAT_I2S | alen; + if (!this->write_channel_mix_()) { + this->mark_failed(); + return; + } + + if (!this->write_analog_gain_()) { + this->mark_failed(); + return; + } + // PLL reference clock: BCK + if (!this->select_page_(0)) { + ESP_LOGE(TAG, "Write failed"); + this->mark_failed(); + return; + } optional pll_ref = this->read_byte(PCM5122_REG_PLL_REF); if (!pll_ref.has_value()) { ESP_LOGE(TAG, "Failed to read PLL_REF"); @@ -67,15 +88,40 @@ void PCM5122::setup() { this->mark_failed(); return; } + + // Release XSMT (soft un-mute) now that init has completed + if (this->enable_pin_ != nullptr) { + this->enable_pin_->digital_write(true); + } } void PCM5122::dump_config() { + const char *channel_mix_str; + switch (this->channel_mix_) { + case PCM5122_CHANNEL_MIX_LEFT_ONLY: + channel_mix_str = "left only"; + break; + case PCM5122_CHANNEL_MIX_RIGHT_ONLY: + channel_mix_str = "right only"; + break; + case PCM5122_CHANNEL_MIX_SWAPPED: + channel_mix_str = "swapped"; + break; + default: + channel_mix_str = "stereo"; + break; + } ESP_LOGCONFIG(TAG, "Audio DAC:"); LOG_I2C_DEVICE(this); ESP_LOGCONFIG(TAG, " Bits per sample: %u\n" + " Analog gain: %s\n" + " Channel mix: %s\n" + " Volume range: %.1f dB to %.1f dB\n" " Muted: %s", - this->bits_per_sample_, YESNO(this->is_muted_)); + this->bits_per_sample_, this->analog_gain_ == PCM5122_ANALOG_GAIN_0DB ? "0 dB" : "-6 dB", + channel_mix_str, this->volume_min_db_, this->volume_max_db_, YESNO(this->is_muted_)); + LOG_PIN(" Enable Pin: ", this->enable_pin_); } bool PCM5122::set_mute_off() { @@ -118,11 +164,11 @@ bool PCM5122::write_mute_() { } bool PCM5122::write_volume_() { - // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFF = mute (-0.5 dB/step). - // Note: volume=0.0 maps to -52.5 dB (still audible), not true silence. + // DVOL register: 0x00 = +24 dB, 0x30 = 0 dB, 0xFE = -103 dB, 0xFF = mute (-0.5 dB/step). + // Note: volume=0.0 maps to volume_min_db_, which is not true silence unless set to -103 dB. // Use set_mute_on() for silence. - const uint8_t dvol_max_volume = 0x30; // 0 dB at full scale - const uint8_t dvol_min_volume = 0x99; // -52.5 dB at minimum + const uint8_t dvol_max_volume = static_cast(lroundf(0x30 - this->volume_max_db_ * 2.0f)); + const uint8_t dvol_min_volume = static_cast(lroundf(0x30 - this->volume_min_db_ * 2.0f)); const uint8_t volume_byte = dvol_max_volume + static_cast(lroundf((1.0f - this->volume_) * (dvol_min_volume - dvol_max_volume))); @@ -137,4 +183,52 @@ bool PCM5122::write_volume_() { return true; } +bool PCM5122::write_analog_gain_() { + uint8_t gain_byte = this->analog_gain_; + if (!this->select_page_(1) || !this->write_byte(PCM5122_REG_ANALOG_GAIN, gain_byte)) { + ESP_LOGE(TAG, "Writing analog gain failed"); + return false; + } + return true; +} + +bool PCM5122::write_channel_mix_() { + uint8_t channel_mix_byte = this->channel_mix_; + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_DAC_DATA_PATH, channel_mix_byte)) { + ESP_LOGE(TAG, "Writing channel mix failed"); + return false; + } + return true; +} + +bool PCM5122::set_standby(bool enable) { + bool prev_standby = this->standby_; + this->standby_ = enable; + if (!this->write_power_control_()) { + this->standby_ = prev_standby; + return false; + } + return true; +} + +bool PCM5122::set_powerdown(bool enable) { + bool prev_powerdown = this->powerdown_; + this->powerdown_ = enable; + if (!this->write_power_control_()) { + this->powerdown_ = prev_powerdown; + return false; + } + return true; +} + +bool PCM5122::write_power_control_() { + uint8_t power_byte = + (this->standby_ ? PCM5122_POWER_CONTROL_RQST : 0) | (this->powerdown_ ? PCM5122_POWER_CONTROL_RQPD : 0); + if (!this->select_page_(0) || !this->write_byte(PCM5122_REG_POWER_CONTROL, power_byte)) { + ESP_LOGE(TAG, "Writing power control failed"); + return false; + } + return true; +} + } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122.h b/esphome/components/pcm5122/pcm5122.h index f86b096c82..199818b06e 100644 --- a/esphome/components/pcm5122/pcm5122.h +++ b/esphome/components/pcm5122/pcm5122.h @@ -3,6 +3,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" +#include "esphome/core/gpio.h" #include "esphome/core/hal.h" namespace esphome::pcm5122 { @@ -10,11 +11,13 @@ namespace esphome::pcm5122 { // Page 0 register addresses static const uint8_t PCM5122_REG_PAGE_SELECT = 0x00; static const uint8_t PCM5122_REG_RESET = 0x01; +static const uint8_t PCM5122_REG_POWER_CONTROL = 0x02; static const uint8_t PCM5122_REG_MUTE = 0x03; static const uint8_t PCM5122_REG_GPIO_ENABLE = 0x08; static const uint8_t PCM5122_REG_PLL_REF = 0x0D; static const uint8_t PCM5122_REG_ERROR_DETECT = 0x25; static const uint8_t PCM5122_REG_AUDIO_FORMAT = 0x28; +static const uint8_t PCM5122_REG_DAC_DATA_PATH = 0x2A; static const uint8_t PCM5122_REG_DVOL_LEFT = 0x3D; static const uint8_t PCM5122_REG_DVOL_RIGHT = 0x3E; static const uint8_t PCM5122_REG_GPIO_OUTPUT_SELECT = 0x50; // Base address; GPIO n uses offset n-1 @@ -23,6 +26,9 @@ static const uint8_t PCM5122_REG_GPIO_OUTPUT = 0x56; static const uint8_t PCM5122_REG_GPIO_INVERT = 0x57; static const uint8_t PCM5122_REG_GPIO_INPUT = 0x77; +// Page 1 register addresses +static const uint8_t PCM5122_REG_ANALOG_GAIN = 0x02; + // Register values for init sequence static const uint8_t PCM5122_RESET_MODULES = 0x10; // RSTM: reset audio modules static const uint8_t PCM5122_AUDIO_FORMAT_I2S = 0x00; // AFMT = I2S (bits [5:4] = 00) @@ -35,19 +41,45 @@ static const uint8_t PCM5122_ERROR_DETECT_DISABLE_DIV_AUTOSET = (1 << 1); static const uint8_t PCM5122_PLL_REF_MASK = (7 << 4); // SREF bits [6:4] static const uint8_t PCM5122_PLL_REF_SOURCE_BCK = (1 << 4); // SREF = 001 (BCK) +// Page 0, Register 2 (Power Control): RQST = standby request, RQPD = powerdown request (§10.5.3) +static const uint8_t PCM5122_POWER_CONTROL_RQST = (1 << 4); +static const uint8_t PCM5122_POWER_CONTROL_RQPD = (1 << 0); + +// Page 1, Register 2 (Analog Gain Control): LAGN/RAGN select 0 dB or -6 dB analog gain (§8.3.5.5) +static const uint8_t PCM5122_ANALOG_GAIN_LAGN = (1 << 4); +static const uint8_t PCM5122_ANALOG_GAIN_RAGN = (1 << 0); + enum PCM5122BitsPerSample : uint8_t { PCM5122_BITS_PER_SAMPLE_16 = 16, PCM5122_BITS_PER_SAMPLE_24 = 24, PCM5122_BITS_PER_SAMPLE_32 = 32, }; -class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { +enum PCM5122AnalogGain : uint8_t { + PCM5122_ANALOG_GAIN_0DB = 0x00, + PCM5122_ANALOG_GAIN_MINUS_6DB = PCM5122_ANALOG_GAIN_LAGN | PCM5122_ANALOG_GAIN_RAGN, +}; + +// Page 0, Register 0x2A (DAC Data Path): AUPL/AUPR select which channel's data feeds each output (§7.4.2.42) +enum PCM5122ChannelMix : uint8_t { + PCM5122_CHANNEL_MIX_STEREO = 0x11, // Left data -> left out, right data -> right out + PCM5122_CHANNEL_MIX_LEFT_ONLY = 0x12, // Left data -> both outputs + PCM5122_CHANNEL_MIX_RIGHT_ONLY = 0x21, // Right data -> both outputs + PCM5122_CHANNEL_MIX_SWAPPED = 0x22, // Left/right outputs swapped +}; + +class PCM5122 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::IO; } void set_bits_per_sample(PCM5122BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + void set_analog_gain(PCM5122AnalogGain analog_gain) { this->analog_gain_ = analog_gain; } + void set_channel_mix(PCM5122ChannelMix channel_mix) { this->channel_mix_ = channel_mix; } + void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; } + void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; } + void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; } bool set_mute_off() override; bool set_mute_on() override; @@ -56,17 +88,30 @@ class PCM5122 : public audio_dac::AudioDac, public Component, public i2c::I2CDev bool is_muted() override; float volume() override; + bool set_standby(bool enable); + bool set_powerdown(bool enable); + friend class PCM5122GPIOPin; protected: bool select_page_(uint8_t page); bool write_mute_(); bool write_volume_(); + bool write_analog_gain_(); + bool write_channel_mix_(); + bool write_power_control_(); - float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) - int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes + GPIOPin *enable_pin_{nullptr}; + float volume_{1.0f}; // Matches chip post-reset DVOL default (0x30 = 0 dB) + float volume_min_db_{-52.5f}; // Matches the previous hardcoded minimum (0x99) + float volume_max_db_{0.0f}; // Matches the previous hardcoded maximum (0x30) + int16_t current_page_{-1}; // -1 = unknown; cached to skip redundant page-select writes bool is_muted_{false}; + bool standby_{false}; + bool powerdown_{false}; PCM5122BitsPerSample bits_per_sample_{PCM5122_BITS_PER_SAMPLE_16}; + PCM5122AnalogGain analog_gain_{PCM5122_ANALOG_GAIN_0DB}; + PCM5122ChannelMix channel_mix_{PCM5122_CHANNEL_MIX_STEREO}; }; } // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/pcm5122_gpio.h b/esphome/components/pcm5122/pcm5122_gpio.h index 8edaa6d3e8..0c750ab278 100644 --- a/esphome/components/pcm5122/pcm5122_gpio.h +++ b/esphome/components/pcm5122/pcm5122_gpio.h @@ -6,7 +6,7 @@ namespace esphome::pcm5122 { -class PCM5122GPIOPin : public GPIOPin, public Parented { +class PCM5122GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch/__init__.py new file mode 100644 index 0000000000..10519da895 --- /dev/null +++ b/esphome/components/pcm5122/switch/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG + +from ..audio_dac import CONF_PCM5122, PCM5122, pcm5122_ns + +PCM5122PowerSwitch = pcm5122_ns.class_("PCM5122PowerSwitch", switch.Switch) + +pcm5122_power_switch_mode = pcm5122_ns.enum("PCM5122PowerSwitchMode") +PCM5122_POWER_SWITCH_MODE_ENUM = { + "standby": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_STANDBY, + "powerdown": pcm5122_power_switch_mode.PCM5122_POWER_SWITCH_MODE_POWERDOWN, +} + +CONFIG_SCHEMA = switch.switch_schema( + PCM5122PowerSwitch, + entity_category=ENTITY_CATEGORY_CONFIG, +).extend( + { + cv.GenerateID(CONF_PCM5122): cv.use_id(PCM5122), + cv.Optional(CONF_POWER_MODE, default="powerdown"): cv.enum( + PCM5122_POWER_SWITCH_MODE_ENUM, lower=True + ), + } +) + + +async def to_code(config): + var = await switch.new_switch(config) + await cg.register_parented(var, config[CONF_PCM5122]) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) diff --git a/esphome/components/pcm5122/switch/power_switch.cpp b/esphome/components/pcm5122/switch/power_switch.cpp new file mode 100644 index 0000000000..45f0be715d --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.cpp @@ -0,0 +1,12 @@ +#include "power_switch.h" + +namespace esphome::pcm5122 { + +void PCM5122PowerSwitch::write_state(bool state) { + bool ok = (this->mode_ == PCM5122_POWER_SWITCH_MODE_STANDBY) ? this->parent_->set_standby(state) + : this->parent_->set_powerdown(state); + if (ok) + this->publish_state(state); +} + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pcm5122/switch/power_switch.h b/esphome/components/pcm5122/switch/power_switch.h new file mode 100644 index 0000000000..47d30f1a9f --- /dev/null +++ b/esphome/components/pcm5122/switch/power_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/switch/switch.h" + +#include "../pcm5122.h" + +namespace esphome::pcm5122 { + +enum PCM5122PowerSwitchMode : uint8_t { + PCM5122_POWER_SWITCH_MODE_STANDBY, + PCM5122_POWER_SWITCH_MODE_POWERDOWN, +}; + +class PCM5122PowerSwitch final : public switch_::Switch, public Parented { + public: + void set_power_mode(PCM5122PowerSwitchMode mode) { this->mode_ = mode; } + + protected: + void write_state(bool state) override; + + PCM5122PowerSwitchMode mode_{PCM5122_POWER_SWITCH_MODE_POWERDOWN}; +}; + +} // namespace esphome::pcm5122 diff --git a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h index 6225956430..9909dc2217 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.h @@ -6,9 +6,9 @@ #include "esphome/core/hal.h" namespace esphome::pi4ioe5v6408 { -class PI4IOE5V6408Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class PI4IOE5V6408Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: PI4IOE5V6408Component() = default; @@ -49,7 +49,7 @@ class PI4IOE5V6408Component : public Component, bool read_gpio_outputs_(); }; -class PI4IOE5V6408GPIOPin : public GPIOPin, public Parented { +class PI4IOE5V6408GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index 1988f574db..a7ae631956 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -1,10 +1,7 @@ #include "pid_autotuner.h" #include "esphome/core/log.h" #include - -#ifndef M_PI -#define M_PI 3.1415926535897932384626433 -#endif +#include namespace esphome::pid { @@ -126,7 +123,7 @@ PIDAutotuner::PIDAutotuneResult PIDAutotuner::update(float setpoint, float proce float osc_ampl = this->amplitude_detector_.get_mean_oscillation_amplitude(); float d = (this->relay_function_.output_positive - this->relay_function_.output_negative) / 2.0f; ESP_LOGVV(TAG, " Relay magnitude: %f", d); - this->ku_ = 4.0f * d / float(M_PI * osc_ampl); + this->ku_ = 4.0f * d / (std::numbers::pi_v * osc_ampl); this->pu_ = this->frequency_detector_.get_mean_oscillation_period(); this->state_ = AUTOTUNE_SUCCEEDED; @@ -300,7 +297,7 @@ bool PIDAutotuner::OscillationFrequencyDetector::is_increase_decrease_symmetrica min_interval = std::min(min_interval, interval); } float ratio = min_interval / float(max_interval); - return ratio >= 0.66; + return ratio >= 0.66f; } // ================== OscillationAmplitudeDetector ================== diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 9e3c89ca4d..7269709ab9 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -11,7 +11,7 @@ namespace esphome::pid { -class PIDClimate : public climate::Climate, public Component { +class PIDClimate final : public climate::Climate, public Component { public: PIDClimate() = default; void setup() override; @@ -108,7 +108,7 @@ class PIDClimate : public climate::Climate, public Component { bool do_publish_ = false; }; -template class PIDAutotuneAction : public Action { +template class PIDAutotuneAction final : public Action { public: PIDAutotuneAction(PIDClimate *parent) : parent_(parent) {} @@ -131,7 +131,7 @@ template class PIDAutotuneAction : public Action { PIDClimate *parent_; }; -template class PIDResetIntegralTermAction : public Action { +template class PIDResetIntegralTermAction final : public Action { public: PIDResetIntegralTermAction(PIDClimate *parent) : parent_(parent) {} @@ -141,7 +141,7 @@ template class PIDResetIntegralTermAction : public Action PIDClimate *parent_; }; -template class PIDSetControlParametersAction : public Action { +template class PIDSetControlParametersAction final : public Action { public: PIDSetControlParametersAction(PIDClimate *parent) : parent_(parent) {} diff --git a/esphome/components/pid/sensor/pid_climate_sensor.h b/esphome/components/pid/sensor/pid_climate_sensor.h index d6bdc66a46..b62d597780 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.h +++ b/esphome/components/pid/sensor/pid_climate_sensor.h @@ -18,7 +18,7 @@ enum PIDClimateSensorType { PID_SENSOR_TYPE_KD, }; -class PIDClimateSensor : public sensor::Sensor, public Component { +class PIDClimateSensor final : public sensor::Sensor, public Component { public: void setup() override; void set_parent(PIDClimate *parent) { parent_ = parent; } diff --git a/esphome/components/pipsolar/output/pipsolar_output.h b/esphome/components/pipsolar/output/pipsolar_output.h index 4a6e4c29d7..6fc013c276 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -10,7 +10,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarOutput : public output::FloatOutput { +class PipsolarOutput final : public output::FloatOutput { public: PipsolarOutput() {} void set_parent(Pipsolar *parent) { this->parent_ = parent; } @@ -27,7 +27,7 @@ class PipsolarOutput : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction : public Action { +template class SetOutputAction final : public Action { public: SetOutputAction(PipsolarOutput *output) : output_(output) {} diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 59332080cf..06c920a6e4 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -56,7 +56,7 @@ struct QFLAGValues { PIPSOLAR_ENTITY_(binary_sensor::BinarySensor, name, polling_command) #define PIPSOLAR_TEXT_SENSOR(name, polling_command) PIPSOLAR_ENTITY_(text_sensor::TextSensor, name, polling_command) -class Pipsolar : public uart::UARTDevice, public PollingComponent { +class Pipsolar final : public uart::UARTDevice, public PollingComponent { // QPIGS values PIPSOLAR_SENSOR(grid_voltage, QPIGS) PIPSOLAR_SENSOR(grid_frequency, QPIGS) diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.h b/esphome/components/pipsolar/switch/pipsolar_switch.h index 20d2640d90..2b8cda9d59 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.h +++ b/esphome/components/pipsolar/switch/pipsolar_switch.h @@ -6,7 +6,7 @@ namespace esphome::pipsolar { class Pipsolar; -class PipsolarSwitch : public switch_::Switch, public Component { +class PipsolarSwitch final : public switch_::Switch, public Component { public: void set_parent(Pipsolar *parent) { this->parent_ = parent; } void set_on_command(const char *command) { this->on_command_ = command; } diff --git a/esphome/components/pixoo/__init__.py b/esphome/components/pixoo/__init__.py new file mode 100644 index 0000000000..b1de57df8f --- /dev/null +++ b/esphome/components/pixoo/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@jesserockz"] diff --git a/esphome/components/pixoo/display.py b/esphome/components/pixoo/display.py new file mode 100644 index 0000000000..764f06d603 --- /dev/null +++ b/esphome/components/pixoo/display.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import display, spi +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_LAMBDA, CONF_MODEL +from esphome.types import ConfigType + +DEPENDENCIES = ["spi"] +AUTO_LOAD = ["split_buffer"] + +CONF_PIXOO_ID = "pixoo_id" + +pixoo_ns = cg.esphome_ns.namespace("pixoo") +Pixoo = pixoo_ns.class_("Pixoo", cg.PollingComponent, display.Display, spi.SPIDevice) +PixooModel = pixoo_ns.enum("PixooModel") + +# Only the 64x64 panel is hardware-verified. Smaller Pixoo panels are assumed to share the +# same protocol; add them here once confirmed. +MODELS = { + "64X64": PixooModel.PIXOO_64, +} + +CONFIG_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(Pixoo), + cv.Optional(CONF_MODEL, default="64X64"): cv.enum(MODELS, upper=True), + } +).extend(spi.spi_device_schema(cs_pin_required=True, default_data_rate=8e6)) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + "pixoo", require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_MODEL]) + await display.register_display(var, config) + await spi.register_spi_device(var, config, write_only=True) + + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/pixoo/light/__init__.py b/esphome/components/pixoo/light/__init__.py new file mode 100644 index 0000000000..7151cdde0b --- /dev/null +++ b/esphome/components/pixoo/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID +from esphome.types import ConfigType + +from ..display import CONF_PIXOO_ID, Pixoo, pixoo_ns + +PixooLight = pixoo_ns.class_("PixooLight", light.LightOutput) + +CONFIG_SCHEMA = light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(PixooLight), + cv.GenerateID(CONF_PIXOO_ID): cv.use_id(Pixoo), + # The LED board applies its own gamma, so default to no gamma correction here. + cv.Optional(CONF_GAMMA_CORRECT, default=0.0): cv.positive_float, + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_PIXOO_ID]) diff --git a/esphome/components/pixoo/light/pixoo_light.h b/esphome/components/pixoo/light/pixoo_light.h new file mode 100644 index 0000000000..67f3cd5024 --- /dev/null +++ b/esphome/components/pixoo/light/pixoo_light.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/pixoo/pixoo.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// Brightness-only light that drives the Pixoo panel's LIGHT command. +class PixooLight : public light::LightOutput, public Parented { + public: + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_panel_brightness(brightness); + } +}; + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp new file mode 100644 index 0000000000..4436b1fb17 --- /dev/null +++ b/esphome/components/pixoo/pixoo.cpp @@ -0,0 +1,201 @@ +#include "pixoo.h" + +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::pixoo { + +static const char *const TAG = "pixoo"; + +// Divoom LED-board packet protocol. +static constexpr uint8_t PACKET_HEAD = 0xAA; +static constexpr uint8_t PACKET_TAIL = 0xBB; +static constexpr uint8_t CMD_DATA = 0x00; +static constexpr uint8_t CMD_LIGHT = 0x01; +static constexpr uint8_t CMD_UNUSED = 0x21; +static constexpr uint8_t CMD_SET_RGB_IOUT = 0x22; +static constexpr size_t PACKET_HEADER_LEN = 4; // head + len(2) + cmd +static constexpr size_t PACKET_STATIC_LEN = 5; // header + tail +static constexpr uint8_t DEFAULT_IOUT = 75; // per-channel LED current / white balance default + +// Pack a `0xAA len cmd data 0xBB` packet into buf; returns the packet length. +static inline size_t build_packet(uint8_t *buf, uint8_t cmd, const uint8_t *data, uint16_t len) { + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = cmd; + if (data != nullptr && len > 0) + std::memcpy(buf + PACKET_HEADER_LEN, data, len); + buf[PACKET_HEADER_LEN + len] = PACKET_TAIL; + return len + PACKET_STATIC_LEN; +} + +// Fill `total` bytes at buf with a single UNUSED padding packet. +static inline void pad_unused(uint8_t *buf, size_t total) { + const uint16_t len = static_cast(total - PACKET_STATIC_LEN); + buf[0] = PACKET_HEAD; + buf[1] = static_cast(len & 0xFF); + buf[2] = static_cast((len >> 8) & 0xFF); + buf[3] = CMD_UNUSED; + buf[total - 1] = PACKET_TAIL; +} + +float Pixoo::get_setup_priority() const { return setup_priority::PROCESSOR; } + +void Pixoo::setup() { + const uint32_t num_pixels = static_cast(this->model_) * this->model_; + this->data_size_ = num_pixels * 3; + // The frame is a DATA packet (header + RGB888 + tail) followed by a DMA-chunk-sized UNUSED + // packet, so the LED board completes its final DMA block. + this->frame_size_ = this->data_size_ + PACKET_STATIC_LEN + DMA_CHUNK; + + if (!this->buffer_.init(this->data_size_)) { + this->mark_failed(LOG_STR("Failed to allocate draw buffer")); + return; + } + + // The frame is shipped in one SPI transfer, so keep it in DMA-capable internal RAM. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->frame_buffer_ = allocator.allocate(this->frame_size_); + if (this->frame_buffer_ == nullptr) { + this->buffer_.free(); + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + std::memset(this->frame_buffer_, 0, this->frame_size_); + // Pre-build the constant DATA-packet framing; only the RGB888 payload changes per frame. + this->frame_buffer_[0] = PACKET_HEAD; + this->frame_buffer_[1] = static_cast(this->data_size_ & 0xFF); + this->frame_buffer_[2] = static_cast((this->data_size_ >> 8) & 0xFF); + this->frame_buffer_[3] = CMD_DATA; + this->frame_buffer_[PACKET_HEADER_LEN + this->data_size_] = PACKET_TAIL; + pad_unused(this->frame_buffer_ + this->data_size_ + PACKET_STATIC_LEN, DMA_CHUNK); + + this->spi_setup(); + + this->buffer_.fill(0x00); + + // Set the per-channel LED current. Brightness is controlled separately via the light platform. + const uint8_t iout[3] = {DEFAULT_IOUT, DEFAULT_IOUT, DEFAULT_IOUT}; + this->send_command_(CMD_SET_RGB_IOUT, iout, 3); + + // Frames are pushed synchronously inside update(), so there is no loop() work to do and the + // component is idle between updates. Marking it done (LOOP_DONE) lets LVGL's + // update_when_display_idle option treat the panel as idle and drive frames on demand. + this->disable_loop(); +} + +void Pixoo::send_command_(uint8_t cmd, const uint8_t *data, uint16_t len) { + std::memset(this->cmd_buffer_, 0, DMA_CHUNK); + const size_t used = build_packet(this->cmd_buffer_, cmd, data, len); + if (DMA_CHUNK - used >= PACKET_STATIC_LEN) + pad_unused(this->cmd_buffer_ + used, DMA_CHUNK - used); + this->enable(); + this->write_array(this->cmd_buffer_, DMA_CHUNK); + this->disable(); +} + +void Pixoo::set_panel_brightness(float brightness) { + const uint8_t pct = static_cast(lroundf(clamp(brightness, 0.0f, 1.0f) * 100.0f)); + this->send_command_(CMD_LIGHT, &pct, 1); +} + +void Pixoo::update() { + this->do_update_(); + for (size_t i = 0; i < this->data_size_; i++) + this->frame_buffer_[PACKET_HEADER_LEN + i] = this->buffer_[i]; + this->enable(); + this->write_array(this->frame_buffer_, this->frame_size_); + this->disable(); +} + +void Pixoo::set_pixel_(uint32_t index, Color color) { + const size_t off = static_cast(index) * 3; + this->buffer_[off] = color.r; + this->buffer_[off + 1] = color.g; + this->buffer_[off + 2] = color.b; +} + +void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { + if (!this->get_clipping().inside(x, y)) + return; + const int side = static_cast(this->model_); + switch (this->rotation_) { + case display::DISPLAY_ROTATION_0_DEGREES: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + std::swap(x, y); + x = side - x - 1; + break; + case display::DISPLAY_ROTATION_180_DEGREES: + x = side - x - 1; + y = side - y - 1; + break; + case display::DISPLAY_ROTATION_270_DEGREES: + std::swap(x, y); + y = side - y - 1; + break; + } + if (x < 0 || x >= side || y < 0 || y >= side) + return; + this->set_pixel_(static_cast(y) * side + x, color); +} + +void Pixoo::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // Fast path for the common LVGL/image blit: RGB565, RGB order, no rotation, no active clipping. + // Anything else defers to the base implementation, which decodes per pixel and routes through + // draw_pixel_at() so rotation, clipping and other color formats stay correct. + // NOTE: the stride/index math and 565->888 expansion below mirror Display::draw_pixels_at (the + // source of truth) -- keep them in sync if the base ever changes its source layout or decoding. + if (bitness != display::COLOR_BITNESS_565 || order != display::COLOR_ORDER_RGB || + this->rotation_ != display::DISPLAY_ROTATION_0_DEGREES || this->is_clipping()) { + display::Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, + x_pad); + return; + } + const int side = static_cast(this->model_); + const size_t line_stride = static_cast(x_offset) + w + x_pad; + for (int y = 0; y != h; y++) { + const int dst_y = y_start + y; + if (dst_y < 0 || dst_y >= side) + continue; + size_t source_idx = (static_cast(y_offset) + y) * line_stride + x_offset; + for (int x = 0; x != w; x++, source_idx++) { + const int dst_x = x_start + x; + if (dst_x < 0 || dst_x >= side) + continue; + const size_t byte_idx = source_idx * 2; + const uint16_t rgb565 = + big_endian ? (ptr[byte_idx] << 8) | ptr[byte_idx + 1] : ptr[byte_idx] | (ptr[byte_idx + 1] << 8); + const uint8_t r5 = (rgb565 >> 11) & 0x1F; + const uint8_t g6 = (rgb565 >> 5) & 0x3F; + const uint8_t b5 = rgb565 & 0x1F; + this->set_pixel_(static_cast(dst_y) * side + dst_x, + Color((r5 << 3) | (r5 >> 2), (g6 << 2) | (g6 >> 4), (b5 << 3) | (b5 >> 2))); + } + } +} + +void Pixoo::fill(Color color) { + if (this->is_clipping()) { + display::Display::fill(color); + return; + } + for (size_t i = 0; i < this->data_size_; i += 3) { + this->buffer_[i] = color.r; + this->buffer_[i + 1] = color.g; + this->buffer_[i + 2] = color.b; + } +} + +void Pixoo::dump_config() { + LOG_DISPLAY("", "Divoom Pixoo", this); + ESP_LOGCONFIG(TAG, " Model: %ux%u", (unsigned) this->model_, (unsigned) this->model_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::pixoo diff --git a/esphome/components/pixoo/pixoo.h b/esphome/components/pixoo/pixoo.h new file mode 100644 index 0000000000..4913ef85db --- /dev/null +++ b/esphome/components/pixoo/pixoo.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/components/spi/spi.h" +#include "esphome/components/split_buffer/split_buffer.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::pixoo { + +// The Pixoo's main board (where ESPHome runs) talks to a separate LED-driver board (a GD32/AT32 +// MCU) over SPI using Divoom's packet protocol: +// 0xAA, len_lo, len_hi, cmd, , 0xBB +// The image is sent as a DATA (0x00) packet carrying width*height*3 bytes of RGB888; brightness is +// a separate LIGHT (0x01) command; the LED current is set once via SET_RGB_IOUT (0x22). Command +// packets are padded out to the LED board's 240-byte DMA chunk with an UNUSED (0x21) packet. +// The model selects the (square) panel side length. +enum PixooModel : uint8_t { + PIXOO_64 = 64, +}; + +class Pixoo : public display::Display, + public spi::SPIDevice { + public: + explicit Pixoo(PixooModel model) : model_(model) {} + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override; + + // Brightness is controlled exclusively via the light platform: send a LIGHT command to the LED + // board (brightness 0..1 -> 0..100%). + void set_panel_brightness(float brightness); + + display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } + + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + protected: + int get_width_internal() override { return static_cast(this->model_); } + int get_height_internal() override { return static_cast(this->model_); } + + void set_pixel_(uint32_t index, Color color); + void send_command_(uint8_t cmd, const uint8_t *data, uint16_t len); + + // Size of the LED board's SPI DMA chunk; the command scratch buffer is one chunk. + static constexpr size_t DMA_CHUNK = 240; + + PixooModel model_; + + size_t data_size_{0}; // RGB888 image bytes: model^2 * 3 + size_t frame_size_{0}; // full SPI frame: DATA packet + trailing UNUSED packet + + split_buffer::SplitBuffer buffer_{}; + uint8_t *frame_buffer_{nullptr}; + uint8_t cmd_buffer_[DMA_CHUNK]{}; +}; + +} // namespace esphome::pixoo diff --git a/esphome/components/pm1006/pm1006.h b/esphome/components/pm1006/pm1006.h index 38ab284f47..b32bb2ba8e 100644 --- a/esphome/components/pm1006/pm1006.h +++ b/esphome/components/pm1006/pm1006.h @@ -7,7 +7,7 @@ namespace esphome::pm1006 { -class PM1006Component : public PollingComponent, public uart::UARTDevice { +class PM1006Component final : public PollingComponent, public uart::UARTDevice { public: PM1006Component() = default; diff --git a/esphome/components/pm2005/pm2005.h b/esphome/components/pm2005/pm2005.h index 9661d082d1..e4ab9ff328 100644 --- a/esphome/components/pm2005/pm2005.h +++ b/esphome/components/pm2005/pm2005.h @@ -11,7 +11,7 @@ enum SensorType { PM2105, }; -class PM2005Component : public PollingComponent, public i2c::I2CDevice { +class PM2005Component final : public PollingComponent, public i2c::I2CDevice { public: void set_sensor_type(SensorType sensor_type) { this->sensor_type_ = sensor_type; } diff --git a/esphome/components/pmsa003i/pmsa003i.h b/esphome/components/pmsa003i/pmsa003i.h index aebe80b711..908b073be1 100644 --- a/esphome/components/pmsa003i/pmsa003i.h +++ b/esphome/components/pmsa003i/pmsa003i.h @@ -26,7 +26,7 @@ struct PM25AQIData { uint16_t checksum; ///< Packet checksum }; -class PMSA003IComponent : public PollingComponent, public i2c::I2CDevice { +class PMSA003IComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index d559f2dec0..c62960e7c3 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -29,7 +29,7 @@ enum class State : uint8_t { WAITING, }; -class PMSX003Component : public uart::UARTDevice, public Component { +class PMSX003Component final : public uart::UARTDevice, public Component { public: PMSX003Component() = default; void setup() override; diff --git a/esphome/components/pmwcs3/pmwcs3.h b/esphome/components/pmwcs3/pmwcs3.h index d669147819..4ce4a5ce9c 100644 --- a/esphome/components/pmwcs3/pmwcs3.h +++ b/esphome/components/pmwcs3/pmwcs3.h @@ -9,7 +9,7 @@ namespace esphome::pmwcs3 { -class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { +class PMWCS3Component final : public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; @@ -32,7 +32,7 @@ class PMWCS3Component : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *vwc_sensor_{nullptr}; }; -template class PMWCS3AirCalibrationAction : public Action { +template class PMWCS3AirCalibrationAction final : public Action { public: PMWCS3AirCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -42,7 +42,7 @@ template class PMWCS3AirCalibrationAction : public Action PMWCS3Component *parent_; }; -template class PMWCS3WaterCalibrationAction : public Action { +template class PMWCS3WaterCalibrationAction final : public Action { public: PMWCS3WaterCalibrationAction(PMWCS3Component *parent) : parent_(parent) {} @@ -52,7 +52,7 @@ template class PMWCS3WaterCalibrationAction : public Action class PMWCS3NewI2cAddressAction : public Action { +template class PMWCS3NewI2cAddressAction final : public Action { public: PMWCS3NewI2cAddressAction(PMWCS3Component *parent) : parent_(parent) {} TEMPLATABLE_VALUE(int, new_address) diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index a26f27ed54..629a697aa5 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -114,7 +114,7 @@ class PN532 : public PollingComponent { CallbackManager on_finished_write_callback_; }; -class PN532BinarySensor : public binary_sensor::BinarySensor { +class PN532BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const nfc::NfcTagUid &uid) { uid_ = uid; } @@ -132,7 +132,7 @@ class PN532BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -template class PN532IsWritingCondition : public Condition, public Parented { +template class PN532IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; diff --git a/esphome/components/pn532_i2c/pn532_i2c.h b/esphome/components/pn532_i2c/pn532_i2c.h index b2a2ac2e18..6495f17599 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.h +++ b/esphome/components/pn532_i2c/pn532_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn532_i2c { -class PN532I2C : public pn532::PN532, public i2c::I2CDevice { +class PN532I2C final : public pn532::PN532, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn532_spi/pn532_spi.h b/esphome/components/pn532_spi/pn532_spi.h index 2bfd4accf7..f29950c423 100644 --- a/esphome/components/pn532_spi/pn532_spi.h +++ b/esphome/components/pn532_spi/pn532_spi.h @@ -8,9 +8,9 @@ namespace esphome::pn532_spi { -class PN532Spi : public pn532::PN532, - public spi::SPIDevice { +class PN532Spi final : public pn532::PN532, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/pn7150/automation.h b/esphome/components/pn7150/automation.h index 0b2e5f5d24..c3f8d3e5d3 100644 --- a/esphome/components/pn7150/automation.h +++ b/esphome/components/pn7150/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7150 { -template class PN7150IsWritingCondition : public Condition, public Parented { +template class PN7150IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.h b/esphome/components/pn7150_i2c/pn7150_i2c.h index 2ea8c8f75c..25b0f3b855 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.h +++ b/esphome/components/pn7150_i2c/pn7150_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7150_i2c { -class PN7150I2C : public pn7150::PN7150, public i2c::I2CDevice { +class PN7150I2C final : public pn7150::PN7150, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160/automation.h b/esphome/components/pn7160/automation.h index 7300c4a8d6..9f03a5a3d6 100644 --- a/esphome/components/pn7160/automation.h +++ b/esphome/components/pn7160/automation.h @@ -6,40 +6,40 @@ namespace esphome::pn7160 { -template class PN7160IsWritingCondition : public Condition, public Parented { +template class PN7160IsWritingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_writing(); } }; -template class EmulationOffAction : public Action, public Parented { +template class EmulationOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_off(); } }; -template class EmulationOnAction : public Action, public Parented { +template class EmulationOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_tag_emulation_on(); } }; -template class PollingOffAction : public Action, public Parented { +template class PollingOffAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_off(); } }; -template class PollingOnAction : public Action, public Parented { +template class PollingOnAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->set_polling_on(); } }; -template class SetCleanModeAction : public Action, public Parented { +template class SetCleanModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->clean_mode(); } }; -template class SetFormatModeAction : public Action, public Parented { +template class SetFormatModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->format_mode(); } }; -template class SetReadModeAction : public Action, public Parented { +template class SetReadModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->read_mode(); } }; -template class SetEmulationMessageAction : public Action, public Parented { +template class SetEmulationMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -49,7 +49,7 @@ template class SetEmulationMessageAction : public Action, } }; -template class SetWriteMessageAction : public Action, public Parented { +template class SetWriteMessageAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, message) TEMPLATABLE_VALUE(bool, include_android_app_record) @@ -59,7 +59,7 @@ template class SetWriteMessageAction : public Action, pub } }; -template class SetWriteModeAction : public Action, public Parented { +template class SetWriteModeAction final : public Action, public Parented { void play(const Ts &...x) override { this->parent_->write_mode(); } }; diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.h b/esphome/components/pn7160_i2c/pn7160_i2c.h index d29fd04fac..2a3b767765 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.h +++ b/esphome/components/pn7160_i2c/pn7160_i2c.h @@ -8,7 +8,7 @@ namespace esphome::pn7160_i2c { -class PN7160I2C : public pn7160::PN7160, public i2c::I2CDevice { +class PN7160I2C final : public pn7160::PN7160, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/pn7160_spi/pn7160_spi.h b/esphome/components/pn7160_spi/pn7160_spi.h index 2d9c1fda11..4f22e5edec 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.h +++ b/esphome/components/pn7160_spi/pn7160_spi.h @@ -12,9 +12,9 @@ namespace esphome::pn7160_spi { static constexpr uint8_t TDD_SPI_READ = 0xFF; static constexpr uint8_t TDD_SPI_WRITE = 0x0A; -class PN7160Spi : public pn7160::PN7160, - public spi::SPIDevice { +class PN7160Spi final : public pn7160::PN7160, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index 4da73e76ae..f094f6e2e9 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -21,7 +21,11 @@ void PowerSupply::dump_config() { LOG_PIN(" Pin: ", this->pin_); } -float PowerSupply::get_setup_priority() const { return setup_priority::IO; } +float PowerSupply::get_setup_priority() const { + if (this->pin_->is_internal() && this->enable_on_boot_) + return setup_priority::POWER; + return setup_priority::IO; +} bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index e096f69e3b..eaf77af32e 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -7,7 +7,7 @@ namespace esphome::power_supply { -class PowerSupply : public Component { +class PowerSupply final : public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_enable_time(uint32_t enable_time) { enable_time_ = enable_time; } diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index c426872728..f3f2f632c9 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -1,3 +1,4 @@ +from esphome import preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID @@ -10,10 +11,17 @@ preferences_ns = cg.esphome_ns.namespace("preferences") IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.Component) CONF_FLASH_WRITE_INTERVAL = "flash_write_interval" +CONF_RTC_STORAGE = "rtc_storage" CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(IntervalSyncer), cv.Optional(CONF_FLASH_WRITE_INTERVAL, default="60s"): cv.update_interval, + # Compile the RTC-backed storage into the ESP32 preferences backend even + # when no other option selects it, so components (including external + # ones) requesting in_flash=false are honoured instead of falling back + # to NVS. No default: absence means "no request" (see + # preferences.validate_rtc_storage for the per-platform rules). + cv.Optional(CONF_RTC_STORAGE): preferences.validate_rtc_storage, } ).extend(cv.COMPONENT_SCHEMA) @@ -26,4 +34,6 @@ async def to_code(config): cg.add_define("USE_PREFERENCES_SYNC_EVERY_LOOP") else: cg.add(var.set_write_interval(write_interval)) + if config.get(CONF_RTC_STORAGE): + preferences.request_rtc_storage() await cg.register_component(var, config) diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 008081f586..bc256c6885 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -14,7 +14,7 @@ namespace esphome::prometheus { -class PrometheusHandler : public AsyncWebHandler, public Component { +class PrometheusHandler final : public AsyncWebHandler, public Component { public: PrometheusHandler(web_server_base::WebServerBase *base) : base_(base) {} diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py new file mode 100644 index 0000000000..36fa69357a --- /dev/null +++ b/esphome/components/provisioning/__init__.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass, field +import logging + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_TIMEOUT, CONF_TIMEOUT +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] +DOMAIN = "provisioning" + +_LOGGER = logging.getLogger(__name__) + +provisioning_ns = cg.esphome_ns.namespace("provisioning") +ProvisioningManager = provisioning_ns.class_("ProvisioningManager", cg.Component) + + +@dataclass +class ProvisioningData: + # Names of the components that registered as a provisioning source this run. + sources: set[str] = field(default_factory=set) + # Names of source components that have their credentials set in the config. + hardcoded_credentials: set[str] = field(default_factory=set) + + +def _get_data() -> ProvisioningData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ProvisioningData() + return CORE.data[DOMAIN] + + +def register_source(name: str) -> None: + """Record that ``name`` is a provisioning source for this configuration. + + A provisioning-capable component (a transport that boots unprovisioned and is + set up by the controller on first connection, or a network interface that + provisions once connected) calls this while its own config is being processed, + typically from a schema validator. `provisioning:` then confirms at least one + source is present without inspecting the full config or knowing about any + specific component. State lives in CORE.data, which is cleared between runs. + """ + _get_data().sources.add(name) + + +def report_hardcoded_credentials(name: str) -> None: + """Record that source component ``name`` has its credentials set in the config. + + A source component calls this from its own validator when it finds baked-in + credentials (a WiFi SSID/password, an API encryption key, ...). `provisioning:` + warns about these, since a device that ships with credentials does not need a + provisioning window. The warning is emitted here, by `provisioning:`, so the + source components stay unaware of it. + """ + _get_data().hardcoded_credentials.add(name) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ProvisioningManager), + cv.Required(CONF_TIMEOUT): cv.All( + cv.positive_not_null_time_period, cv.positive_time_period_milliseconds + ), + cv.Optional(CONF_ON_TIMEOUT): automation.validate_automation(single=True), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate the provisioning setup once every component has been processed. + + Sources register during their own config validation, so by final validation + both the source set and the hardcoded-credentials set are complete. + """ + data = _get_data() + if not data.sources: + raise cv.Invalid( + "'provisioning' requires at least one provisioning-capable component: " + "configure a network interface such as 'wifi:' or 'ethernet:', or enable " + "'api:' with 'encryption:' and no 'key:' so the device boots " + "unprovisioned and is configured on first connection." + ) + if data.hardcoded_credentials: + _LOGGER.warning( + "'provisioning' is configured, but credentials are set in the " + "configuration for: %s. A device that uses a provisioning window should " + "ship without credentials so they are set on first connection; " + "hardcoding them makes the window pointless.", + ", ".join(sorted(data.hardcoded_credentials)), + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_PROVISIONING") + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + if on_timeout := config.get(CONF_ON_TIMEOUT): + await automation.build_automation(var.get_timeout_trigger(), [], on_timeout) diff --git a/esphome/components/provisioning/provisioning.cpp b/esphome/components/provisioning/provisioning.cpp new file mode 100644 index 0000000000..02c089bfed --- /dev/null +++ b/esphome/components/provisioning/provisioning.cpp @@ -0,0 +1,92 @@ +#include "esphome/components/provisioning/provisioning.h" +#ifdef USE_PROVISIONING +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#ifdef USE_NETWORK +#include "esphome/components/network/util.h" +#endif + +#include + +namespace esphome::provisioning { + +static const char *const TAG = "provisioning"; + +ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + nullptr; + +ProvisioningManager::ProvisioningManager() { + global_provisioning_manager = this; +#ifdef USE_NETWORK + // Network connectivity is a built-in provisioning source. Registered here rather + // than from a source's setup() because connectivity is universal, not a pluggable + // transport; loop() latches it provisioned once the device has connected. + this->network_source_ = this->register_source(); +#endif +} + +uint8_t ProvisioningManager::register_source() { + if (this->source_count_ >= MAX_SOURCES) { + // Defensive: only a handful of sources exist in practice. Fail loudly rather + // than shifting past the mask width (undefined behavior). The returned index is + // ignored by set_source_provisioned()'s bounds check. + ESP_LOGE(TAG, "Too many provisioning sources (max %u)", MAX_SOURCES); + return this->source_count_; + } + uint8_t source = this->source_count_++; + this->registered_mask_ |= (1UL << source); + return source; +} + +void ProvisioningManager::loop() { + // Sources register during their own setup() (at various priorities), and this + // loop() also runs while waiting on a slow component during setup. Evaluating the + // provisioning state before every source has registered could conclude + // "provisioned" prematurely and disable_loop() for good, defeating the window -- + // so do nothing until all setup() calls are done. + if (!App.is_setup_complete()) + return; + +#ifdef USE_NETWORK + // Latch the built-in connectivity source once the device has been reachable via + // any interface. network::is_connected() aggregates wifi/ethernet/modem/... (OR + // across interfaces), and a disabled interface never connects so it never + // contributes. Latched: a later link drop does not un-provision -- the RAM-only + // window still reopens only on reboot. + if ((this->provisioned_mask_ & (1UL << this->network_source_)) == 0 && network::is_connected()) + this->set_source_provisioned(this->network_source_, true); +#endif + + // The window is resolved once the device is provisioned or the window has closed; + // there is nothing left to track, so stop running entirely. Config validation + // guarantees at least one source, so is_provisioned() is never vacuously true here. + if (this->closed_ || this->is_provisioned()) { + this->disable_loop(); + return; + } + // The window timer runs from boot (millis since boot). The closed state is not + // persisted, so a reboot reopens the window. + if (this->timeout_ != 0 && App.get_loop_component_start_time() > this->timeout_) { + this->close_window_(); + } +} + +void ProvisioningManager::close_window_() { + this->closed_ = true; + ESP_LOGW(TAG, "Window expired; cycle power to reopen window"); + // Notify internal consumers first (transports disconnect clients, Improv stops), + // then fire the user-facing automation. + this->closed_callback_.call(); + this->timeout_trigger_.trigger(); +} + +void ProvisioningManager::dump_config() { + ESP_LOGCONFIG(TAG, + "Provisioning:\n" + " Timeout: %" PRIu32 "ms\n" + " Provisioned: %s", + this->timeout_, YESNO(this->is_provisioned())); +} + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/provisioning/provisioning.h b/esphome/components/provisioning/provisioning.h new file mode 100644 index 0000000000..e21b8f3ef0 --- /dev/null +++ b/esphome/components/provisioning/provisioning.h @@ -0,0 +1,96 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_PROVISIONING +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::provisioning { + +// Central provisioning-window manager (EN18031). A device that ships unprovisioned +// (secure transports enabled with no credentials, configured by the controller on +// first connection) opens a provisioning window at boot. Each transport that needs +// provisioning registers as a "source" and reports its state; the device is +// considered provisioned once every registered source is provisioned. +// +// Network connectivity is a built-in source: a device with a network interface but +// no other provisioning-capable component (no api encryption, etc.) is still +// considered provisioned once it has connected via any interface -- so an +// Improv-only device reports its state correctly. +// +// If the window times out while still unprovisioned it closes: the closed state is +// RAM-only (a power cycle / reset reopens it) and the `on_timeout` automation fires. +// Components query window_pending()/closed() to suppress reboot timeouts and refuse +// further provisioning. This manager owns no transport knowledge; transports +// (api, and later mqtt/wireguard/...) drive it through the source API. +class ProvisioningManager : public Component { + public: + // Maximum number of provisioning sources, limited by the width of the state masks. + static constexpr uint8_t MAX_SOURCES = 32; + + ProvisioningManager(); + + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BEFORE_CONNECTION; } + + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } + + // Register a provisioning source. Returns a bit index the source uses to report + // its state via set_source_provisioned(). Call once, from the source's setup(). + uint8_t register_source(); + // Report whether the given source currently holds valid credentials. + void set_source_provisioned(uint8_t source, bool provisioned) { + if (source >= MAX_SOURCES) + return; + if (provisioned) { + this->provisioned_mask_ |= (1UL << source); + } else { + this->provisioned_mask_ &= ~(1UL << source); + } + } + + // True once every registered source is provisioned. Config validation guarantees + // at least one source, and the built-in connectivity source registers in the + // constructor, so registered_mask_ is never zero in practice. + bool is_provisioned() const { return (this->provisioned_mask_ & this->registered_mask_) == this->registered_mask_; } + // True while provisioning is still pending: the device is unprovisioned, whether + // the window is still open or has already closed. Reboot timeouts are suppressed + // while this holds so the device never auto-reboots (and silently reopens the + // window) while unprovisioned. + bool window_pending() const { return !this->is_provisioned(); } + // True once the window has expired without the device being provisioned. + bool closed() const { return this->closed_; } + + // Register a callback fired once when the window closes (runtime expiry). Used + // internally by transports/Improv to stop accepting provisioning. The user-facing + // on_timeout automation is wired to get_timeout_trigger() instead. + template void add_on_closed_callback(F &&callback) { + this->closed_callback_.add(std::forward(callback)); + } + Trigger<> *get_timeout_trigger() { return &this->timeout_trigger_; } + + protected: + void close_window_(); + + Trigger<> timeout_trigger_; + LazyCallbackManager closed_callback_; + uint32_t timeout_{0}; + uint32_t registered_mask_{0}; + uint32_t provisioned_mask_{0}; + uint8_t source_count_{0}; + bool closed_{false}; +#ifdef USE_NETWORK + // Built-in connectivity source (see loop()): registered in the constructor and + // latched provisioned once the device has connected via any network interface. + uint8_t network_source_{0}; +#endif +}; + +extern ProvisioningManager *global_provisioning_manager; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::provisioning +#endif // USE_PROVISIONING diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 296ea6c08c..84683e9a25 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -10,9 +10,11 @@ from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C5, VARIANT_ESP32C61, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, idf_version, @@ -57,8 +59,10 @@ SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32C61: (TYPE_QUAD,), + VARIANT_ESP32H4: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), + VARIANT_ESP32S31: (TYPE_OCTAL,), VARIANT_ESP32P4: (TYPE_HEX,), } @@ -67,8 +71,10 @@ SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32C61: (40, 80), + VARIANT_ESP32H4: (32, 64), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), + VARIANT_ESP32S31: (40, 100, 200, 250), VARIANT_ESP32P4: (20, 100, 200), } @@ -145,10 +151,8 @@ def validate_psram_mode(config): raise cv.Invalid("ECC is only available in octal mode.") if config[CONF_MODE] == TYPE_OCTAL: variant = get_esp32_variant() - if variant != VARIANT_ESP32S3: - raise cv.Invalid( - f"Octal PSRAM is only supported on ESP32-S3, not {variant}" - ) + if TYPE_OCTAL not in SPIRAM_MODES.get(variant, ()): + raise cv.Invalid(f"Octal PSRAM is not supported on {variant}") return config diff --git a/esphome/components/psram/psram.h b/esphome/components/psram/psram.h index 22a49588b4..8549ef2959 100644 --- a/esphome/components/psram/psram.h +++ b/esphome/components/psram/psram.h @@ -6,7 +6,7 @@ namespace esphome::psram { -class PsramComponent : public Component { +class PsramComponent final : public Component { void dump_config() override; }; diff --git a/esphome/components/pulse_counter/automation.h b/esphome/components/pulse_counter/automation.h index 14264e87b3..380ef02304 100644 --- a/esphome/components/pulse_counter/automation.h +++ b/esphome/components/pulse_counter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_counter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseCounterSensor *pulse_counter) : pulse_counter_(pulse_counter) {} diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.h b/esphome/components/pulse_counter/pulse_counter_sensor.h index 4f23ef1548..6704d3dc31 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.h +++ b/esphome/components/pulse_counter/pulse_counter_sensor.h @@ -59,7 +59,7 @@ struct HwPulseCounterStorage : public PulseCounterStorageBase { PulseCounterStorageBase *get_storage(bool hw_pcnt = false); -class PulseCounterSensor : public sensor::Sensor, public PollingComponent { +class PulseCounterSensor final : public sensor::Sensor, public PollingComponent { public: explicit PulseCounterSensor(bool hw_pcnt = false) : storage_(*get_storage(hw_pcnt)) {} diff --git a/esphome/components/pulse_meter/automation.h b/esphome/components/pulse_meter/automation.h index 1def89c3d3..885922a22a 100644 --- a/esphome/components/pulse_meter/automation.h +++ b/esphome/components/pulse_meter/automation.h @@ -6,7 +6,7 @@ namespace esphome::pulse_meter { -template class SetTotalPulsesAction : public Action { +template class SetTotalPulsesAction final : public Action { public: SetTotalPulsesAction(PulseMeterSensor *pulse_meter) : pulse_meter_(pulse_meter) {} diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.h b/esphome/components/pulse_meter/pulse_meter_sensor.h index 243a64bf05..9fc99a440b 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.h +++ b/esphome/components/pulse_meter/pulse_meter_sensor.h @@ -9,7 +9,7 @@ namespace esphome::pulse_meter { -class PulseMeterSensor : public sensor::Sensor, public Component { +class PulseMeterSensor final : public sensor::Sensor, public Component { public: enum InternalFilterMode { FILTER_EDGE = 0, diff --git a/esphome/components/pulse_width/pulse_width.h b/esphome/components/pulse_width/pulse_width.h index f77766a961..7a79b80678 100644 --- a/esphome/components/pulse_width/pulse_width.h +++ b/esphome/components/pulse_width/pulse_width.h @@ -26,7 +26,7 @@ class PulseWidthSensorStore { volatile uint32_t last_rise_{0}; }; -class PulseWidthSensor : public sensor::Sensor, public PollingComponent { +class PulseWidthSensor final : public sensor::Sensor, public PollingComponent { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void setup() override { this->store_.setup(this->pin_); } diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.h b/esphome/components/pvvx_mithermometer/display/pvvx_display.h index e1aebae7a5..d231111c58 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.h +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.h @@ -31,7 +31,7 @@ enum UNIT { using pvvx_writer_t = display::DisplayWriter; -class PVVXDisplay : public ble_client::BLEClientNode, public PollingComponent { +class PVVXDisplay final : public ble_client::BLEClientNode, public PollingComponent { public: void set_writer(pvvx_writer_t &&writer) { this->writer_ = writer; } void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h index b5d6da21ef..382e41d210 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.h @@ -18,7 +18,7 @@ struct ParseResult { int raw_offset; }; -class PVVXMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class PVVXMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index 1d86803cc2..eae5e6e7bc 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -21,7 +21,7 @@ class PylontechListener { virtual void dump_config(); }; -class PylontechComponent : public PollingComponent, public uart::UARTDevice { +class PylontechComponent final : public PollingComponent, public uart::UARTDevice { public: PylontechComponent(); diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 36576e8332..1403d3445d 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechSensor : public PylontechListener { +class PylontechSensor final : public PylontechListener { public: PylontechSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h index 30921b13f4..3ba4f1fd4e 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.h @@ -5,7 +5,7 @@ namespace esphome::pylontech { -class PylontechTextSensor : public PylontechListener { +class PylontechTextSensor final : public PylontechListener { public: PylontechTextSensor(int8_t bat_num); void dump_config() override; diff --git a/esphome/components/pzem004t/pzem004t.h b/esphome/components/pzem004t/pzem004t.h index 71fc1e70ad..42135f0fbd 100644 --- a/esphome/components/pzem004t/pzem004t.h +++ b/esphome/components/pzem004t/pzem004t.h @@ -6,7 +6,7 @@ namespace esphome::pzem004t { -class PZEM004T : public PollingComponent, public uart::UARTDevice { +class PZEM004T final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } diff --git a/esphome/components/pzem004t/sensor.py b/esphome/components/pzem004t/sensor.py index 51b1ab2d80..7e55fd9e7e 100644 --- a/esphome/components/pzem004t/sensor.py +++ b/esphome/components/pzem004t/sensor.py @@ -58,6 +58,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "pzem004t", baud_rate=9600, require_rx=True, require_tx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 264604fedc..a3ad7e1167 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -11,7 +11,7 @@ namespace esphome::pzemac { template class ResetEnergyAction; -class PZEMAC : public PollingComponent, public modbus::ModbusDevice { +class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -38,7 +38,7 @@ class PZEMAC : public PollingComponent, public modbus::ModbusDevice { void reset_energy_(); }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMAC *pzemac) : pzemac_(pzemac) {} diff --git a/esphome/components/pzemac/sensor.py b/esphome/components/pzemac/sensor.py index c134bc19c1..4e228f6aa3 100644 --- a/esphome/components/pzemac/sensor.py +++ b/esphome/components/pzemac/sensor.py @@ -26,11 +26,12 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemac_ns = cg.esphome_ns.namespace("pzemac") -PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusDevice) +PZEMAC = pzemac_ns.class_("PZEMAC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemac_ns.class_("ResetEnergyAction", automation.Action) @@ -97,10 +98,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemac", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index 6a7e840448..7d14a5ed4b 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -9,7 +9,7 @@ namespace esphome::pzemdc { -class PZEMDC : public PollingComponent, public modbus::ModbusDevice { +class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } @@ -31,7 +31,7 @@ class PZEMDC : public PollingComponent, public modbus::ModbusDevice { sensor::Sensor *energy_sensor_{nullptr}; }; -template class ResetEnergyAction : public Action { +template class ResetEnergyAction final : public Action { public: ResetEnergyAction(PZEMDC *pzemdc) : pzemdc_(pzemdc) {} diff --git a/esphome/components/pzemdc/sensor.py b/esphome/components/pzemdc/sensor.py index 3291be4c34..40cfe7b08a 100644 --- a/esphome/components/pzemdc/sensor.py +++ b/esphome/components/pzemdc/sensor.py @@ -20,11 +20,12 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] pzemdc_ns = cg.esphome_ns.namespace("pzemdc") -PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusDevice) +PZEMDC = pzemdc_ns.class_("PZEMDC", cg.PollingComponent, modbus.ModbusClientDevice) # Actions ResetEnergyAction = pzemdc_ns.class_("ResetEnergyAction", automation.Action) @@ -79,10 +80,17 @@ async def reset_energy_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("pzemdc", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_VOLTAGE in config: conf = config[CONF_VOLTAGE] diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index 5b04a904b5..ba6a71f97d 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -3,6 +3,7 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" #include +#include namespace esphome::qmc5883l { @@ -173,7 +174,7 @@ void QMC5883LComponent::read_sensor_() { const float y = int16_t(raw[1]) * mg_per_bit * 0.1f; const float z = int16_t(raw[2]) * mg_per_bit * 0.1f; - float heading = atan2f(0.0f - x, y) * 180.0f / M_PI; + float heading = atan2f(0.0f - x, y) * 180.0f / std::numbers::pi_v; float temp = NAN; if (this->temperature_sensor_ != nullptr) { diff --git a/esphome/components/qmc5883l/qmc5883l.h b/esphome/components/qmc5883l/qmc5883l.h index 6b8ffa0f40..faef423f8c 100644 --- a/esphome/components/qmc5883l/qmc5883l.h +++ b/esphome/components/qmc5883l/qmc5883l.h @@ -26,7 +26,7 @@ enum QMC5883LOversampling { QMC5883L_SAMPLING_64 = 0b11, }; -class QMC5883LComponent : public PollingComponent, public i2c::I2CDevice { +class QMC5883LComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/qmi8658/__init__.py b/esphome/components/qmi8658/__init__.py new file mode 100644 index 0000000000..67838dbc3c --- /dev/null +++ b/esphome/components/qmi8658/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["i2c", "motion"] + +CONF_QMI8658_ID = "qmi8658_id" +# C++ namespace / class +qmi8658_ns = cg.esphome_ns.namespace("qmi8658") +QMI8658Component = qmi8658_ns.class_("QMI8658Component", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/qmi8658/motion.py b/esphome/components/qmi8658/motion.py new file mode 100644 index 0000000000..26169189c2 --- /dev/null +++ b/esphome/components/qmi8658/motion.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_GYROSCOPE_ODR, + CONF_GYROSCOPE_RANGE, +) +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv + +from . import QMI8658Component, qmi8658_ns + +# Enum proxies (must match the C++ enum values exactly) +QMI8658AccelRange = qmi8658_ns.enum("QMI8658AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_2G, + "4G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_4G, + "8G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_8G, + "16G": QMI8658AccelRange.QMI8658_ACCEL_RANGE_16G, +} + +QMI8658GyroRange = qmi8658_ns.enum("QMI8658GyroRange") +GYRO_RANGE_OPTIONS = { + "16DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_16, + "32DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_32, + "64DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_64, + "128DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_128, + "256DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_256, + "512DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_512, + "1024DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_1024, + "2048DPS": QMI8658GyroRange.QMI8658_GYRO_RANGE_2048, +} + +QMI8658AccelODR = qmi8658_ns.enum("QMI8658AccelODR") +ACCEL_ODR_OPTIONS = { + "31_25HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_31_25, + "62_5HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_62_5, + "125HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_125, + "250HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_250, + "500HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_500, + "1000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_1000, + "2000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_2000, + "4000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_4000, + "8000HZ": QMI8658AccelODR.QMI8658_ACCEL_ODR_8000, +} + +QMI8658GyroODR = qmi8658_ns.enum("QMI8658GyroODR") +GYRO_ODR_OPTIONS = { + "31_25HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_31_25, + "62_5HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_62_5, + "125HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_125, + "250HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_250, + "500HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_500, + "1000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_1000, + "2000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_2000, + "4000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_4000, + "8000HZ": QMI8658GyroODR.QMI8658_GYRO_ODR_8000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(QMI8658Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="4G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="1000HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2048DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="1000HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6B)) +) + + +# Code generation +async def to_code(config): + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Hardware configuration + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) + cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp new file mode 100644 index 0000000000..2fd457d290 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -0,0 +1,136 @@ +#include "qmi8658.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::qmi8658 { + +static const char *const TAG = "qmi8658"; + +// Acceleration scale (g per LSB), indexed by accel_range_ >> 4. +// Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). +static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, +}; + +// Angular rate scale (°/s per LSB), indexed by gyro_range_ >> 4. +static constexpr float GYRO_SCALE[] = { + 16.0f / 32768.0f, 32.0f / 32768.0f, 64.0f / 32768.0f, 128.0f / 32768.0f, + 256.0f / 32768.0f, 512.0f / 32768.0f, 1024.0f / 32768.0f, 2048.0f / 32768.0f, +}; + +void QMI8658Component::setup() { + MotionComponent::setup(); + + // 1. Verify chip ID + uint8_t who_am_i = 0; + if (!this->read_byte(QMI8658_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != QMI8658_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, QMI8658_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // 2. Soft reset + if (!this->write_byte(QMI8658_REG_RESET, QMI8658_RESET_CMD)) { + this->mark_failed(); + return; + } + delay(15); // spec: wait for reset to complete + + // 3. Serial interface: enable register address auto-increment + if (!this->write_byte(QMI8658_REG_CTRL1, QMI8658_CTRL1_VALUE)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL1")); + return; + } + + // 4. Configure accelerometer (CTRL2 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL2, (uint8_t) (this->accel_range_) | (uint8_t) (this->accel_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL2")); + return; + } + + // 5. Configure gyroscope (CTRL3 = range | ODR) + if (!this->write_byte(QMI8658_REG_CTRL3, (uint8_t) (this->gyro_range_) | (uint8_t) (this->gyro_odr_))) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL3")); + return; + } + + // 6. Disable the built-in low-pass filters (leave raw data to the motion pipeline) + if (!this->write_byte(QMI8658_REG_CTRL5, 0x00)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL5")); + this->mark_failed(); + return; + } + + // 7. Enable accelerometer and gyroscope + if (!this->write_byte(QMI8658_REG_CTRL7, QMI8658_CTRL7_ACC_EN | QMI8658_CTRL7_GYR_EN)) { + this->mark_failed(LOG_STR("Failed to write REG_CTRL7")); + return; + } + + ESP_LOGCONFIG(TAG, "QMI8658 initialised successfully"); +} + +void QMI8658Component::dump_config() { + ESP_LOGCONFIG(TAG, "QMI8658 IMU:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " Communication failed!"); + return; + } + + static constexpr const char *const ACCEL_RANGE_STRS[] = {"±2g", "±4g", "±8g", "±16g"}; + static constexpr const char *const GYRO_RANGE_STRS[] = {"±16°/s", "±32°/s", "±64°/s", "±128°/s", + "±256°/s", "±512°/s", "±1024°/s", "±2048°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[this->accel_range_ >> 4]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[this->gyro_range_ >> 4]); + MotionComponent::dump_config(); +} + +bool QMI8658Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Read temperature + accel + gyro in one contiguous block starting at TEMP_L. + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(QMI8658_REG_TEMP_L, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + + // Data is little-endian (low byte first). + float scale = ACCEL_SCALE[this->accel_range_ >> 4]; + int16_t raw_x = encode_uint16(raw_data[ACC_OFFS + 1], raw_data[ACC_OFFS + 0]); + int16_t raw_y = encode_uint16(raw_data[ACC_OFFS + 3], raw_data[ACC_OFFS + 2]); + int16_t raw_z = encode_uint16(raw_data[ACC_OFFS + 5], raw_data[ACC_OFFS + 4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + scale = GYRO_SCALE[this->gyro_range_ >> 4]; + raw_x = encode_uint16(raw_data[GYR_OFFS + 1], raw_data[GYR_OFFS + 0]); + raw_y = encode_uint16(raw_data[GYR_OFFS + 3], raw_data[GYR_OFFS + 2]); + raw_z = encode_uint16(raw_data[GYR_OFFS + 5], raw_data[GYR_OFFS + 4]); + ESP_LOGV(TAG, "Read raw gyro data: %d, %d, %d", raw_x, raw_y, raw_z); + data.angular_rate[motion::X_AXIS] = raw_x * scale; + data.angular_rate[motion::Y_AXIS] = raw_y * scale; + data.angular_rate[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: signed 16-bit, °C = raw / 256 + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + this->temperature_callback_.call(raw_t / 256.0f); + return true; +} + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/qmi8658.h b/esphome/components/qmi8658/qmi8658.h new file mode 100644 index 0000000000..ce31a2b7a9 --- /dev/null +++ b/esphome/components/qmi8658/qmi8658.h @@ -0,0 +1,112 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::qmi8658 { + +// Register map +static constexpr uint8_t QMI8658_REG_WHO_AM_I = 0x00; +static constexpr uint8_t QMI8658_REG_REVISION = 0x01; +static constexpr uint8_t QMI8658_REG_CTRL1 = 0x02; // serial interface / auto-increment +static constexpr uint8_t QMI8658_REG_CTRL2 = 0x03; // accelerometer ODR / range +static constexpr uint8_t QMI8658_REG_CTRL3 = 0x04; // gyroscope ODR / range +static constexpr uint8_t QMI8658_REG_CTRL5 = 0x06; // low-pass filter +static constexpr uint8_t QMI8658_REG_CTRL7 = 0x08; // sensor enable +static constexpr uint8_t QMI8658_REG_STATUS0 = 0x2E; +static constexpr uint8_t QMI8658_REG_TEMP_BASE = 0x33; // start of the data block +static constexpr uint8_t QMI8658_REG_TEMP_L = 0x33; // Low byte of temperature +static constexpr uint8_t QMI8658_REG_AX_L = 0x35; +static constexpr uint8_t QMI8658_REG_GX_L = 0x3B; +static constexpr uint8_t QMI8658_REG_RESET = 0x60; + +// One contiguous read covers temperature (2) + accel (6) + gyro (6) starting at TEMP_L. +static constexpr uint8_t REG_READ_LEN = QMI8658_REG_GX_L + 6 - QMI8658_REG_TEMP_BASE; // 0x41 - 0x33 = 14 +static constexpr uint8_t TEMP_OFFS = QMI8658_REG_TEMP_L - QMI8658_REG_TEMP_BASE; // 0 +static constexpr uint8_t ACC_OFFS = QMI8658_REG_AX_L - QMI8658_REG_TEMP_BASE; // 2 +static constexpr uint8_t GYR_OFFS = QMI8658_REG_GX_L - QMI8658_REG_TEMP_BASE; // 8 + +static constexpr uint8_t QMI8658_WHO_AM_I_VALUE = 0x05; +static constexpr uint8_t QMI8658_RESET_CMD = 0xB0; +// CTRL1: bit6 ADDR_AI (register address auto-increment); little-endian, 4-wire SPI +static constexpr uint8_t QMI8658_CTRL1_VALUE = 0x40; +// CTRL7: aEN (bit0) | gEN (bit1) +static constexpr uint8_t QMI8658_CTRL7_ACC_EN = 0x01; +static constexpr uint8_t QMI8658_CTRL7_GYR_EN = 0x02; + +// Accelerometer range options (CTRL2 bits 6:4) +enum QMI8658AccelRange : uint8_t { + QMI8658_ACCEL_RANGE_2G = 0x00, + QMI8658_ACCEL_RANGE_4G = 0x10, + QMI8658_ACCEL_RANGE_8G = 0x20, + QMI8658_ACCEL_RANGE_16G = 0x30, +}; + +// Accelerometer ODR options (CTRL2 bits 3:0) +enum QMI8658AccelODR : uint8_t { + QMI8658_ACCEL_ODR_8000 = 0x00, + QMI8658_ACCEL_ODR_4000 = 0x01, + QMI8658_ACCEL_ODR_2000 = 0x02, + QMI8658_ACCEL_ODR_1000 = 0x03, + QMI8658_ACCEL_ODR_500 = 0x04, + QMI8658_ACCEL_ODR_250 = 0x05, + QMI8658_ACCEL_ODR_125 = 0x06, + QMI8658_ACCEL_ODR_62_5 = 0x07, + QMI8658_ACCEL_ODR_31_25 = 0x08, +}; + +// Gyroscope range options (CTRL3 bits 6:4) +enum QMI8658GyroRange : uint8_t { + QMI8658_GYRO_RANGE_16 = 0x00, + QMI8658_GYRO_RANGE_32 = 0x10, + QMI8658_GYRO_RANGE_64 = 0x20, + QMI8658_GYRO_RANGE_128 = 0x30, + QMI8658_GYRO_RANGE_256 = 0x40, + QMI8658_GYRO_RANGE_512 = 0x50, + QMI8658_GYRO_RANGE_1024 = 0x60, + QMI8658_GYRO_RANGE_2048 = 0x70, +}; + +// Gyroscope ODR options (CTRL3 bits 3:0) +enum QMI8658GyroODR : uint8_t { + QMI8658_GYRO_ODR_8000 = 0x00, + QMI8658_GYRO_ODR_4000 = 0x01, + QMI8658_GYRO_ODR_2000 = 0x02, + QMI8658_GYRO_ODR_1000 = 0x03, + QMI8658_GYRO_ODR_500 = 0x04, + QMI8658_GYRO_ODR_250 = 0x05, + QMI8658_GYRO_ODR_125 = 0x06, + QMI8658_GYRO_ODR_62_5 = 0x07, + QMI8658_GYRO_ODR_31_25 = 0x08, +}; + +// Main component class +class QMI8658Component : public motion::MotionComponent, public i2c::I2CDevice { + public: + // Lifecycle + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters + void set_accel_range(QMI8658AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(QMI8658AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(QMI8658GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(QMI8658GyroODR o) { this->gyro_odr_ = o; } + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + + // Config + QMI8658AccelRange accel_range_{QMI8658_ACCEL_RANGE_4G}; + QMI8658AccelODR accel_odr_{QMI8658_ACCEL_ODR_1000}; + QMI8658GyroRange gyro_range_{QMI8658_GYRO_RANGE_2048}; + QMI8658GyroODR gyro_odr_{QMI8658_GYRO_ODR_1000}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::qmi8658 diff --git a/esphome/components/qmi8658/sensor.py b/esphome/components/qmi8658/sensor.py new file mode 100644 index 0000000000..80b0512361 --- /dev/null +++ b/esphome/components/qmi8658/sensor.py @@ -0,0 +1,39 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj + +from . import CONF_QMI8658_ID, QMI8658Component + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_QMI8658_ID): cv.use_id(QMI8658Component), + } +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_QMI8658_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index bb47e7b0f5..547991f75e 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -276,7 +276,7 @@ void QMP6988Component::write_oversampling_temperature_(QMP6988Oversampling overs void QMP6988Component::calculate_altitude_(float pressure, float temp) { float altitude; - altitude = (pow((101325 / pressure), 1 / 5.257) - 1) * (temp + 273.15) / 0.0065; + altitude = (powf((101325 / pressure), 1 / 5.257f) - 1) * (temp + 273.15f) / 0.0065f; this->qmp6988_data_.altitude = altitude; } diff --git a/esphome/components/qmp6988/qmp6988.h b/esphome/components/qmp6988/qmp6988.h index 41759478b8..ffea32eb18 100644 --- a/esphome/components/qmp6988/qmp6988.h +++ b/esphome/components/qmp6988/qmp6988.h @@ -67,7 +67,7 @@ using qmp6988_data_t = struct Qmp6988Data { qmp6988_ik_data_t ik; }; -class QMP6988Component : public PollingComponent, public i2c::I2CDevice { +class QMP6988Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/qr_code/qr_code.h b/esphome/components/qr_code/qr_code.h index ab4c587b6d..f8ca1660f6 100644 --- a/esphome/components/qr_code/qr_code.h +++ b/esphome/components/qr_code/qr_code.h @@ -13,7 +13,7 @@ class Display; } // namespace display namespace qr_code { -class QrCode : public Component { +class QrCode final : public Component { public: void draw(display::Display *buff, uint16_t x_offset, uint16_t y_offset, Color color, int scale); diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index fa77cc5f76..8a1bf0d4c2 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -53,9 +53,9 @@ enum Model { RM67162, }; -class QspiDbi : public display::DisplayBuffer, - public spi::SPIDevice { +class QspiDbi final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model(const char *model) { this->model_ = model; } void update() override; diff --git a/esphome/components/qwiic_pir/qwiic_pir.h b/esphome/components/qwiic_pir/qwiic_pir.h index 339632a508..8d3b8fb321 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.h +++ b/esphome/components/qwiic_pir/qwiic_pir.h @@ -29,7 +29,7 @@ enum DebounceMode { static const uint8_t QWIIC_PIR_DEVICE_ID = 0x72; -class QwiicPIRComponent : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { +class QwiicPIRComponent final : public Component, public i2c::I2CDevice, public binary_sensor::BinarySensor { public: void setup() override; void loop() override; diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.h b/esphome/components/radon_eye_ble/radon_eye_listener.h index ceca736e78..30e3ccc1ea 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.h +++ b/esphome/components/radon_eye_ble/radon_eye_listener.h @@ -7,7 +7,7 @@ namespace esphome::radon_eye_ble { -class RadonEyeListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RadonEyeListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.h b/esphome/components/radon_eye_rd200/radon_eye_rd200.h index 48e075c2d6..401402a137 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.h +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.h @@ -13,7 +13,7 @@ namespace esphome::radon_eye_rd200 { -class RadonEyeRD200 : public PollingComponent, public ble_client::BLEClientNode { +class RadonEyeRD200 final : public PollingComponent, public ble_client::BLEClientNode { public: RadonEyeRD200(); diff --git a/esphome/components/rc522/rc522.h b/esphome/components/rc522/rc522.h index 45473e04b0..fd3c819696 100644 --- a/esphome/components/rc522/rc522.h +++ b/esphome/components/rc522/rc522.h @@ -251,7 +251,7 @@ class RC522 : public PollingComponent { } error_code_{NONE}; }; -class RC522BinarySensor : public binary_sensor::BinarySensor { +class RC522BinarySensor final : public binary_sensor::BinarySensor { public: void set_uid(const std::vector &uid) { uid_ = uid; } @@ -269,7 +269,7 @@ class RC522BinarySensor : public binary_sensor::BinarySensor { bool found_{false}; }; -class RC522Trigger : public Trigger { +class RC522Trigger final : public Trigger { public: void process(std::vector &data); }; diff --git a/esphome/components/rc522_i2c/rc522_i2c.h b/esphome/components/rc522_i2c/rc522_i2c.h index bd6f2269d8..9144241fe7 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.h +++ b/esphome/components/rc522_i2c/rc522_i2c.h @@ -6,7 +6,7 @@ namespace esphome::rc522_i2c { -class RC522I2C : public rc522::RC522, public i2c::I2CDevice { +class RC522I2C final : public rc522::RC522, public i2c::I2CDevice { public: void dump_config() override; diff --git a/esphome/components/rc522_spi/rc522_spi.h b/esphome/components/rc522_spi/rc522_spi.h index 54caf5c117..2809718308 100644 --- a/esphome/components/rc522_spi/rc522_spi.h +++ b/esphome/components/rc522_spi/rc522_spi.h @@ -14,9 +14,9 @@ */ namespace esphome::rc522_spi { -class RC522Spi : public rc522::RC522, - public spi::SPIDevice { +class RC522Spi final : public rc522::RC522, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index c9c6a546ab..2eb76a1087 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace esphome::rd03d { @@ -233,7 +234,7 @@ void RD03DComponent::publish_target_(uint8_t target_num, int16_t x, int16_t y, i // Angle is measured from the Y axis (radar forward direction) if (target.angle != nullptr) { if (valid) { - float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / M_PI; + float angle = std::atan2(static_cast(x), static_cast(y)) * 180.0f / std::numbers::pi_v; target.angle->publish_state(angle); } else { target.angle->publish_state(NAN); diff --git a/esphome/components/rd03d/rd03d.h b/esphome/components/rd03d/rd03d.h index 8bf7b423be..e4ec6aafb2 100644 --- a/esphome/components/rd03d/rd03d.h +++ b/esphome/components/rd03d/rd03d.h @@ -37,7 +37,7 @@ struct TargetSensor { }; #endif -class RD03DComponent : public Component, public uart::UARTDevice { +class RD03DComponent final : public Component, public uart::UARTDevice { public: void setup() override; void loop() override; diff --git a/esphome/components/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index cbc54ad02b..a65213d576 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -29,6 +29,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rdm6300", baud_rate=9600, require_rx=True +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rdm6300/rdm6300.h b/esphome/components/rdm6300/rdm6300.h index f088f7de4c..4aa31b8d60 100644 --- a/esphome/components/rdm6300/rdm6300.h +++ b/esphome/components/rdm6300/rdm6300.h @@ -13,7 +13,7 @@ namespace esphome::rdm6300 { class RDM6300BinarySensor; class RDM6300Trigger; -class RDM6300Component : public Component, public uart::UARTDevice { +class RDM6300Component final : public Component, public uart::UARTDevice { public: void loop() override; @@ -28,7 +28,7 @@ class RDM6300Component : public Component, public uart::UARTDevice { uint32_t last_id_{0}; }; -class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { +class RDM6300BinarySensor final : public binary_sensor::BinarySensorInitiallyOff { public: void set_id(uint32_t id) { id_ = id; } @@ -46,7 +46,7 @@ class RDM6300BinarySensor : public binary_sensor::BinarySensorInitiallyOff { uint32_t id_; }; -class RDM6300Trigger : public Trigger { +class RDM6300Trigger final : public Trigger { public: void process(uint32_t uid) { this->trigger(uid); } }; diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index f21dd40828..47bad6826f 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -28,9 +28,6 @@ class MideaData { bool is_valid() const { return this->data_[OFFSET_CS] == this->calc_cs_(); } void finalize() { this->data_[OFFSET_CS] = this->calc_cs_(); } bool is_compliment(const MideaData &rhs) const; - /// @deprecated Allocates heap memory. Use to_str() instead. Removed in 2026.7.0. - ESPDEPRECATED("Allocates heap memory. Use to_str() instead. Removed in 2026.7.0.", "2026.1.0") - std::string to_string() const { return format_hex_pretty(this->data_.data(), this->data_.size()); } // NOLINT /// Buffer size for to_str(): 6 bytes = "AA.BB.CC.DD.EE.FF\0" static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(6); /// Format to buffer, returns pointer to buffer diff --git a/esphome/components/remote_base/raw_protocol.h b/esphome/components/remote_base/raw_protocol.h index 1bcf390b62..f043d95eb4 100644 --- a/esphome/components/remote_base/raw_protocol.h +++ b/esphome/components/remote_base/raw_protocol.h @@ -31,7 +31,7 @@ class RawBinarySensor : public RemoteReceiverBinarySensorBase { size_t len_; }; -class RawTrigger : public Trigger, public Component, public RemoteReceiverListener { +class RawTrigger final : public Trigger, public Component, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { this->trigger(src.get_raw_data()); diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 0b1109267f..4e2ed4b71c 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -256,7 +256,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin }; template -class RemoteReceiverTrigger : public Trigger, public RemoteReceiverListener { +class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 53a0f8fb77..ad9c4b5a18 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -118,7 +118,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", - rp2040="1000b", + rp2="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, @@ -248,7 +248,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index 222dae8f7f..36152d8854 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_receiver { diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index cc707346eb..f9ec054fe3 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -14,7 +14,7 @@ namespace esphome::remote_receiver { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); @@ -55,11 +55,11 @@ struct RemoteReceiverComponentStore { }; #endif -class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, - public Component +class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { @@ -93,11 +93,11 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, std::string error_string_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) RemoteReceiverComponentStore store_; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) HighFrequencyLoopRequester high_freq_; #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 1163fc86eb..521c3daf87 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -185,7 +185,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, }, } ) diff --git a/esphome/components/remote_transmitter/automation.h b/esphome/components/remote_transmitter/automation.h index 8da4cfd95d..a1b0926451 100644 --- a/esphome/components/remote_transmitter/automation.h +++ b/esphome/components/remote_transmitter/automation.h @@ -7,7 +7,8 @@ namespace esphome::remote_transmitter { -template class DigitalWriteAction : public Action, public Parented { +template +class DigitalWriteAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, value) void play(const Ts &...x) override { this->parent_->digital_write(this->value_.value(x...)); } diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 51a3c0b1d4..49c711330b 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index d30966e3da..e2d33d13cc 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -33,11 +33,11 @@ struct RemoteTransmitterComponentStore { #endif #endif -class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, - public Component +class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBase, + public Component #if defined(USE_ESP32) && SOC_RMT_SUPPORTED , - public remote_base::RemoteRMTChannel + public remote_base::RemoteRMTChannel #endif { public: @@ -64,7 +64,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); diff --git a/esphome/components/resampler/speaker/resampler_speaker.h b/esphome/components/resampler/speaker/resampler_speaker.h index f482ce4b88..3255bf1fe8 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.h +++ b/esphome/components/resampler/speaker/resampler_speaker.h @@ -14,7 +14,7 @@ namespace esphome::resampler { -class ResamplerSpeaker : public Component, public speaker::Speaker { +class ResamplerSpeaker final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return esphome::setup_priority::DATA; } void dump_config() override; diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index b646fb509a..ecb77795ff 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -10,7 +10,7 @@ enum ResistanceConfiguration { DOWNSTREAM, }; -class ResistanceSensor : public Component, public sensor::Sensor { +class ResistanceSensor final : public Component, public sensor::Sensor { public: void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_configuration(ResistanceConfiguration configuration) { configuration_ = configuration; } diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index 67b4a2bfd1..dc9ec8eadc 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -5,7 +5,7 @@ namespace esphome::restart { -class RestartSwitch : public switch_::Switch, public Component { +class RestartSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9ca47fe862..9863379b79 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -80,6 +80,16 @@ _CALLBACK_AUTOMATIONS = ( ), ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "rf_bridge", + baud_rate=19200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index cec32e0406..549cce72df 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -195,10 +195,7 @@ void RFBridgeComponent::learn() { this->flush(); } -void RFBridgeComponent::dump_config() { - ESP_LOGCONFIG(TAG, "RF_Bridge:"); - this->check_uart_settings(19200); -} +void RFBridgeComponent::dump_config() { ESP_LOGCONFIG(TAG, "RF_Bridge:"); } void RFBridgeComponent::start_advanced_sniffing() { ESP_LOGI(TAG, "Advanced Sniffing on"); diff --git a/esphome/components/rf_bridge/rf_bridge.h b/esphome/components/rf_bridge/rf_bridge.h index 2f91459076..5ad75650ab 100644 --- a/esphome/components/rf_bridge/rf_bridge.h +++ b/esphome/components/rf_bridge/rf_bridge.h @@ -44,7 +44,7 @@ struct RFBridgeAdvancedData { std::string code; }; -class RFBridgeComponent : public uart::UARTDevice, public Component { +class RFBridgeComponent final : public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; @@ -76,7 +76,7 @@ class RFBridgeComponent : public uart::UARTDevice, public Component { CallbackManager advanced_data_callback_; }; -template class RFBridgeSendCodeAction : public Action { +template class RFBridgeSendCodeAction final : public Action { public: RFBridgeSendCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, sync) @@ -97,7 +97,7 @@ template class RFBridgeSendCodeAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeSendAdvancedCodeAction : public Action { +template class RFBridgeSendAdvancedCodeAction final : public Action { public: RFBridgeSendAdvancedCodeAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint8_t, length) @@ -116,7 +116,7 @@ template class RFBridgeSendAdvancedCodeAction : public Action class RFBridgeLearnAction : public Action { +template class RFBridgeLearnAction final : public Action { public: RFBridgeLearnAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -126,7 +126,7 @@ template class RFBridgeLearnAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeStartAdvancedSniffingAction : public Action { +template class RFBridgeStartAdvancedSniffingAction final : public Action { public: RFBridgeStartAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -136,7 +136,7 @@ template class RFBridgeStartAdvancedSniffingAction : public Acti RFBridgeComponent *parent_; }; -template class RFBridgeStopAdvancedSniffingAction : public Action { +template class RFBridgeStopAdvancedSniffingAction final : public Action { public: RFBridgeStopAdvancedSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -146,7 +146,7 @@ template class RFBridgeStopAdvancedSniffingAction : public Actio RFBridgeComponent *parent_; }; -template class RFBridgeStartBucketSniffingAction : public Action { +template class RFBridgeStartBucketSniffingAction final : public Action { public: RFBridgeStartBucketSniffingAction(RFBridgeComponent *parent) : parent_(parent) {} @@ -156,7 +156,7 @@ template class RFBridgeStartBucketSniffingAction : public Action RFBridgeComponent *parent_; }; -template class RFBridgeSendRawAction : public Action { +template class RFBridgeSendRawAction final : public Action { public: RFBridgeSendRawAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, raw) @@ -167,7 +167,7 @@ template class RFBridgeSendRawAction : public Action { RFBridgeComponent *parent_; }; -template class RFBridgeBeepAction : public Action { +template class RFBridgeBeepAction final : public Action { public: RFBridgeBeepAction(RFBridgeComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(uint16_t, duration) diff --git a/esphome/components/rgb/rgb_light_output.h b/esphome/components/rgb/rgb_light_output.h index f0d599cf57..5893abf1d7 100644 --- a/esphome/components/rgb/rgb_light_output.h +++ b/esphome/components/rgb/rgb_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgb { -class RGBLightOutput : public light::LightOutput { +class RGBLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbct/rgbct_light_output.h b/esphome/components/rgbct/rgbct_light_output.h index 84ecb232cc..d6f7aaef78 100644 --- a/esphome/components/rgbct/rgbct_light_output.h +++ b/esphome/components/rgbct/rgbct_light_output.h @@ -7,7 +7,7 @@ namespace esphome::rgbct { -class RGBCTLightOutput : public light::LightOutput { +class RGBCTLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbw/rgbw_light_output.h b/esphome/components/rgbw/rgbw_light_output.h index ae96eb2024..a957104457 100644 --- a/esphome/components/rgbw/rgbw_light_output.h +++ b/esphome/components/rgbw/rgbw_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbw { -class RGBWLightOutput : public light::LightOutput { +class RGBWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rgbww/rgbww_light_output.h b/esphome/components/rgbww/rgbww_light_output.h index de5ee993f8..6608c65d9c 100644 --- a/esphome/components/rgbww/rgbww_light_output.h +++ b/esphome/components/rgbww/rgbww_light_output.h @@ -6,7 +6,7 @@ namespace esphome::rgbww { -class RGBWWLightOutput : public light::LightOutput { +class RGBWWLightOutput final : public light::LightOutput { public: void set_red(output::FloatOutput *red) { red_ = red; } void set_green(output::FloatOutput *green) { green_ = green; } diff --git a/esphome/components/rotary_encoder/rotary_encoder.h b/esphome/components/rotary_encoder/rotary_encoder.h index 8a56da4fe2..286267baed 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.h +++ b/esphome/components/rotary_encoder/rotary_encoder.h @@ -41,7 +41,7 @@ struct RotaryEncoderSensorStore { static void gpio_intr(RotaryEncoderSensorStore *arg); }; -class RotaryEncoderSensor : public sensor::Sensor, public Component { +class RotaryEncoderSensor final : public sensor::Sensor, public Component { public: void set_pin_a(InternalGPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(InternalGPIOPin *pin_b) { pin_b_ = pin_b; } @@ -106,7 +106,7 @@ class RotaryEncoderSensor : public sensor::Sensor, public Component { CallbackManager listeners_{}; }; -template class RotaryEncoderSetValueAction : public Action { +template class RotaryEncoderSetValueAction final : public Action { public: RotaryEncoderSetValueAction(RotaryEncoderSensor *encoder) : encoder_(encoder) {} TEMPLATABLE_VALUE(int, value) diff --git a/esphome/components/router/speaker/router_speaker.h b/esphome/components/router/speaker/router_speaker.h index 13b58a1c72..801d0906ce 100644 --- a/esphome/components/router/speaker/router_speaker.h +++ b/esphome/components/router/speaker/router_speaker.h @@ -13,7 +13,7 @@ namespace esphome::router { -class Router : public Component, public speaker::Speaker { +class Router final : public Component, public speaker::Speaker { public: float get_setup_priority() const override { return setup_priority::DATA; } @@ -77,7 +77,7 @@ class Router : public Component, public speaker::Speaker { std::atomic active_output_idx_{0}; }; -template class SwitchOutputAction : public Action { +template class SwitchOutputAction final : public Action { public: explicit SwitchOutputAction(Router *parent) : parent_(parent) {} TEMPLATABLE_VALUE(speaker::Speaker *, target) diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2/__init__.py similarity index 88% rename from esphome/components/rp2040/__init__.py rename to esphome/components/rp2/__init__.py index f98cde7968..fad9d3d25b 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -21,7 +21,7 @@ from esphome.const import ( KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, - PLATFORM_RP2040, + PLATFORM_RP2, ThreadModel, ) from esphome.core import ( @@ -40,27 +40,34 @@ from .const import ( KEY_BOARD, KEY_LWIP_OPTS, KEY_PIO_FILES, - KEY_RP2040, + KEY_RP2, KEY_VARIANT, MCU_TO_VARIANT, STANDARD_BOARDS, VARIANT_FRIENDLY, VARIANTS, - rp2040_ns, + rp2_ns, ) # force import gpio to register pin schema -from .gpio import rp2040_pin_to_code # noqa: F401 +from .gpio import rp2_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +# Legacy top-level YAML keys that route here. The framework +# (esphome/loader.py + esphome/config.py) handles both the deprecation +# warning and the key-rename pass; this declaration is the only place a +# component needs to opt in. See ComponentManifest.aliases for details. +ALIASES = ["rp2040"] +ALIAS_REMOVAL_VERSION = "2027.7.0" + def get_board() -> str: """Return the configured board name.""" - return CORE.data[KEY_RP2040][KEY_BOARD] + return CORE.data[KEY_RP2][KEY_BOARD] def board_has_wifi() -> bool: @@ -90,22 +97,22 @@ def board_id_has_wifi(board_id: str) -> bool: def set_core_data(config: ConfigType) -> ConfigType: - CORE.data[KEY_RP2040] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2040 + CORE.data[KEY_RP2] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_RP2 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( config[CONF_FRAMEWORK][CONF_VERSION] ) - CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] - CORE.data[KEY_RP2040][KEY_VARIANT] = config[CONF_VARIANT] + CORE.data[KEY_RP2][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2][KEY_VARIANT] = config[CONF_VARIANT] - CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} + CORE.data[KEY_RP2][KEY_PIO_FILES] = {} return config def get_rp2040_variant(core_obj: EsphomeCore | None = None) -> str: - return (core_obj or CORE).data[KEY_RP2040][KEY_VARIANT] + return (core_obj or CORE).data[KEY_RP2][KEY_VARIANT] def only_on_variant( @@ -121,7 +128,7 @@ def only_on_variant( unsupported = [unsupported] def validator_(obj: Any) -> Any: - if not CORE.is_rp2040: + if not CORE.is_rp2: raise cv.Invalid(f"{msg_prefix} is only available on RP2040") variant = get_rp2040_variant() if supported is not None and variant not in supported: @@ -140,15 +147,13 @@ def only_on_variant( def get_download_types(storage_json): """Binary-download entries for a built RP2040 firmware. - Used by: - - esphome.dashboard (legacy "Download .bin" button) - - device-builder (esphome/device-builder) — same dispatch via - ``importlib.import_module(f"esphome.components.{platform}")`` - then ``module.get_download_types(storage)``. The contract is - "returns ``list[dict]`` with at least ``title`` / - ``description`` / ``file`` / ``download`` keys"; please keep - the shape stable so the new dashboard's download panel - doesn't have to special-case per-platform schemas. + Used by device-builder (esphome/device-builder), via + ``importlib.import_module(f"esphome.components.{platform}")`` + then ``module.get_download_types(storage)``. The contract is + "returns ``list[dict]`` with at least ``title`` / + ``description`` / ``file`` / ``download`` keys"; please keep + the shape stable so the download panel + doesn't have to special-case per-platform schemas. """ return [ { @@ -189,12 +194,11 @@ def _parse_platform_version(value): # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) -# and platformio.ini/platformio-lint.ini in the esphome-docker-base repository # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags @@ -204,8 +208,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460" def _arduino_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(5, 6, 0), None), + "dev": (cv.Version(5, 6, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(5, 6, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } @@ -240,8 +244,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional( + CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY + ): cv.string_strict, + cv.Optional( + CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY + ): _parse_platform_version, } ), _arduino_check_versions, @@ -309,13 +317,18 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config): - cg.add(rp2040_ns.setup_preferences()) + cg.add(rp2_ns.setup_preferences()) # Allow LDF to properly discover dependency including those in preprocessor # conditionals cg.add_platformio_option("lib_ldf_mode", "chain+") cg.add_platformio_option("lib_compat_mode", "strict") cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_build_flag("-DUSE_RP2") + # USE_RP2040 kept defined as a backwards-compat alias for external + # custom components that may still test for it. Internal code uses + # USE_RP2 (the canonical name for the RP2 chip family — covers + # RP2040, RP2350, and any future RP2-series chips). cg.add_build_flag("-DUSE_RP2040") cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") @@ -330,7 +343,8 @@ async def to_code(config): conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") - cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2_FRAMEWORK_ARDUINO") + cg.add_build_flag("-DUSE_RP2040_FRAMEWORK_ARDUINO") # back-compat alias # cg.add_build_flag("-DPICO_BOARD=pico_w") cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( @@ -362,8 +376,12 @@ async def to_code(config): cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - cg.add_define("USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) - cg.add_define("USE_RP2040_CRASH_HANDLER") + cg.add_define("USE_RP2_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT]) + cg.add_define( + "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] + ) # back-compat alias + cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() @@ -468,7 +486,7 @@ def _configure_lwip() -> None: } # Store for copy_files() to generate the header - CORE.data[KEY_RP2040][KEY_LWIP_OPTS] = lwip_defines + CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines # Add a pre-build extra script that injects our lwip_override directory # into CCFLAGS so our lwipopts.h shadows the framework's version. @@ -503,7 +521,7 @@ def _generate_lwipopts_h() -> None: """ from jinja2 import Environment - lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS) + lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: return @@ -530,7 +548,7 @@ def add_pio_file(component: str, key: str, data: str): raise EsphomeError( f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/esphome/issues" ) from e - CORE.data[KEY_RP2040][KEY_PIO_FILES][key] = data + CORE.data[KEY_RP2][KEY_PIO_FILES][key] = data def generate_pio_files() -> bool: @@ -539,7 +557,7 @@ def generate_pio_files() -> bool: shutil.rmtree(CORE.relative_build_path("src/pio"), ignore_errors=True) includes: list[str] = [] - files = CORE.data[KEY_RP2040][KEY_PIO_FILES] + files = CORE.data[KEY_RP2][KEY_PIO_FILES] if not files: return False for key, data in files.items(): @@ -584,7 +602,7 @@ def copy_files(): # RP2040 crash handler stacktrace decoding -# Matches output from esphome/components/rp2040/crash_handler.cpp +# Matches output from esphome/components/rp2/crash_handler.cpp _CRASH_RE = re.compile(r"CRASH DETECTED ON PREVIOUS BOOT") _CRASH_ADDR_RE = re.compile( r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" diff --git a/esphome/components/rp2040/boards.jinja2 b/esphome/components/rp2/boards.jinja2 similarity index 56% rename from esphome/components/rp2040/boards.jinja2 rename to esphome/components/rp2/boards.jinja2 index 989fb83701..9223009c26 100644 --- a/esphome/components/rp2040/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= {{ cyw43_gpio_offset }} to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = {{ cyw43_gpio_offset }} CYW43_MAX_GPIO = {{ cyw43_max_gpio }} DEFAULT_MAX_PIN = {{ default_max_pin }} -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { {%- for name, pins in board_pins %} {{ name | repr }}: {{ pins | format_pins }}, {%- endfor %} @@ -23,3 +23,10 @@ BOARDS = { }, {%- endfor %} } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/boards.py b/esphome/components/rp2/boards.py similarity index 96% rename from esphome/components/rp2040/boards.py rename to esphome/components/rp2/boards.py index 1f2b3a93f4..94d0ebbb60 100644 --- a/esphome/components/rp2040/boards.py +++ b/esphome/components/rp2/boards.py @@ -1,14 +1,14 @@ # Auto-generated by generate_boards.py — do not edit manually -# To regenerate: python esphome/components/rp2040/generate_boards.py +# To regenerate: python esphome/components/rp2/generate_boards.py # arduino-pico maps pins >= 64 to CYW43 wireless chip GPIOs CYW43_GPIO_OFFSET = 64 CYW43_MAX_GPIO = 66 DEFAULT_MAX_PIN = 29 -RP2040_BASE_PINS = {} +RP2_BASE_PINS = {} -RP2040_BOARD_PINS = { +RP2_BOARD_PINS = { "0xcb_helios": { "LED": 17, "MISO": 20, @@ -865,6 +865,30 @@ RP2040_BOARD_PINS = { "SS": 17, "TX": 0, }, + "pcbcupid_glyph_2040": { + "LED": 0, + "MISO": 8, + "MOSI": 7, + "RX": 13, + "SCK": 6, + "SCL": 21, + "SDA": 20, + "SS": 5, + "TX": 12, + }, + "pcbcupid_glyph_mini_2040": { + "LED": 16, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 9, + "SCL1": 27, + "SDA": 8, + "SDA1": 26, + "SS": 5, + "TX": 0, + }, "picolume": { "LED": 25, "MISO": 16, @@ -1079,6 +1103,18 @@ RP2040_BOARD_PINS = { "SDA": 6, "TX": 0, }, + "seeed_xiao_rp2040_plus": { + "LED": 25, + "MISO": 4, + "MOSI": 3, + "RX": 1, + "SCK": 2, + "SCL": 7, + "SCL1": 21, + "SDA": 6, + "SDA1": 20, + "TX": 0, + }, "seeed_xiao_rp2350": { "LED": 25, "MISO": 4, @@ -1102,6 +1138,18 @@ RP2040_BOARD_PINS = { "SS": 21, "TX": 0, }, + "soldered_nula_ethernet_w55rp20": { + "MISO": 4, + "MOSI": 7, + "RX": 1, + "SCK": 6, + "SCL": 3, + "SCL1": 29, + "SDA": 2, + "SDA1": 28, + "SS": 5, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -1899,6 +1947,16 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "pcbcupid_glyph_2040": { + "name": "PCBCupid Glyph 2040", + "mcu": "rp2040", + "max_pin": 29, + }, + "pcbcupid_glyph_mini_2040": { + "name": "PCBCupid Glyph Mini 2040", + "mcu": "rp2040", + "max_pin": 29, + }, "picolume": { "name": "PicoLume Transceiver", "mcu": "rp2040", @@ -2021,6 +2079,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "seeed_xiao_rp2040_plus": { + "name": "Seeed XIAO RP2040 Plus", + "mcu": "rp2040", + "max_pin": 29, + }, "seeed_xiao_rp2350": { "name": "Seeed XIAO RP2350", "mcu": "rp2350", @@ -2031,6 +2094,11 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_ethernet_w55rp20": { + "name": "Soldered Electronics NULA Ethernet W55RP20", + "mcu": "rp2040", + "max_pin": 29, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", @@ -2231,3 +2299,10 @@ BOARDS = { "max_pin": 29, }, } + +# Deprecated: use RP2_BASE_PINS / RP2_BOARD_PINS instead. Kept as back-compat +# aliases so external custom components / tooling that imported the legacy +# names via the ``rp2040`` package alias keep working. +# Scheduled for removal in 2027.7.0. +RP2040_BASE_PINS = RP2_BASE_PINS +RP2040_BOARD_PINS = RP2_BOARD_PINS diff --git a/esphome/components/rp2040/build_pio.py.script b/esphome/components/rp2/build_pio.py.script similarity index 100% rename from esphome/components/rp2040/build_pio.py.script rename to esphome/components/rp2/build_pio.py.script diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2/const.py similarity index 91% rename from esphome/components/rp2040/const.py rename to esphome/components/rp2/const.py index 959753d95b..515f9f007c 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2/const.py @@ -2,7 +2,7 @@ import esphome.codegen as cg KEY_BOARD = "board" KEY_LWIP_OPTS = "lwip_opts" -KEY_RP2040 = "rp2040" +KEY_RP2 = "rp2" KEY_PIO_FILES = "pio_files" KEY_VARIANT = "variant" @@ -31,4 +31,4 @@ STANDARD_BOARDS = { VARIANT_RP2350: "rpipico2w", } -rp2040_ns = cg.esphome_ns.namespace("rp2040") +rp2_ns = cg.esphome_ns.namespace("rp2") diff --git a/esphome/components/rp2/core.cpp b/esphome/components/rp2/core.cpp new file mode 100644 index 0000000000..2509f47a86 --- /dev/null +++ b/esphome/components/rp2/core.cpp @@ -0,0 +1,6 @@ +#ifdef USE_RP2 + +// HAL functions live in hal.cpp. core.cpp is intentionally empty for +// rp2 — there is no extra component bootstrap to keep here. + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/core.h b/esphome/components/rp2/core.h similarity index 53% rename from esphome/components/rp2040/core.h rename to esphome/components/rp2/core.h index db8937a8a3..c53c3719eb 100644 --- a/esphome/components/rp2040/core.h +++ b/esphome/components/rp2/core.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include extern "C" unsigned long ulMainGetRunTimeCounterValue(); -namespace esphome::rp2040 {} // namespace esphome::rp2040 +namespace esphome::rp2 {} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp similarity index 97% rename from esphome/components/rp2040/crash_handler.cpp rename to esphome/components/rp2/crash_handler.cpp index f9eb42a0f8..5553a24a60 100644 --- a/esphome/components/rp2040/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -1,7 +1,7 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #include "esphome/core/log.h" @@ -51,9 +51,9 @@ static inline bool is_code_addr(uint32_t val) { static constexpr size_t MAX_BACKTRACE = 4; -namespace esphome::rp2040 { +namespace esphome::rp2 { -static const char *const TAG = "rp2040.crash"; +static const char *const TAG = "rp2.crash"; // Placed in .noinit so BSS zero-init cannot race with crash_handler_read_and_clear(). // The valid field is explicitly cleared in crash_handler_read_and_clear() instead. @@ -117,7 +117,7 @@ void crash_handler_log() { ESP_LOGE(TAG, "%s", hint); } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 // --- HardFault handler --- // Overrides the weak isr_hardfault from arduino-pico's crt0.S. @@ -236,5 +236,5 @@ extern "C" void __attribute__((naked, used)) isr_hardfault() { : "i"(hard_fault_handler_c)); } -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/crash_handler.h b/esphome/components/rp2/crash_handler.h similarity index 66% rename from esphome/components/rp2040/crash_handler.h rename to esphome/components/rp2/crash_handler.h index 78e8ede08c..8c43d9fd3b 100644 --- a/esphome/components/rp2040/crash_handler.h +++ b/esphome/components/rp2/crash_handler.h @@ -1,12 +1,12 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER -namespace esphome::rp2040 { +namespace esphome::rp2 { /// Read crash data from watchdog scratch registers and clear them. void crash_handler_read_and_clear(); @@ -17,7 +17,7 @@ void crash_handler_log(); /// Returns true if crash data was found this boot. bool crash_handler_has_data(); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040_CRASH_HANDLER -#endif // USE_RP2040 +#endif // USE_RP2_CRASH_HANDLER +#endif // USE_RP2 diff --git a/esphome/components/rp2040/generate_boards.py b/esphome/components/rp2/generate_boards.py similarity index 98% rename from esphome/components/rp2040/generate_boards.py rename to esphome/components/rp2/generate_boards.py index b1a0b17ca3..33eb1b3058 100644 --- a/esphome/components/rp2040/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -1,6 +1,6 @@ """Generate boards.py from arduino-pico board definitions. -Usage: python esphome/components/rp2040/generate_boards.py +Usage: python esphome/components/rp2/generate_boards.py """ import json diff --git a/esphome/components/rp2040/gpio.cpp b/esphome/components/rp2/gpio.cpp similarity index 81% rename from esphome/components/rp2040/gpio.cpp rename to esphome/components/rp2/gpio.cpp index 4b3c98104c..0dbb124a26 100644 --- a/esphome/components/rp2040/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,12 +1,12 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "gpio.h" #include "esphome/core/log.h" namespace esphome { -namespace rp2040 { +namespace rp2 { -static const char *const TAG = "rp2040"; +static const char *const TAG = "rp2"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) @@ -30,7 +30,7 @@ struct ISRPinArg { bool inverted; }; -ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { +ISRInternalGPIOPin RP2GPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->pin_; arg->inverted = this->inverted_; @@ -38,7 +38,7 @@ ISRInternalGPIOPin RP2040GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -60,25 +60,23 @@ void RP2040GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::Inte attachInterrupt(pin_, func, arduino_mode, arg); } -void RP2040GPIOPin::pin_mode(gpio::Flags flags) { +void RP2GPIOPin::pin_mode(gpio::Flags flags) { pinMode(pin_, flags_to_mode(flags, pin_)); // NOLINT } -size_t RP2040GPIOPin::dump_summary(char *buffer, size_t len) const { - return snprintf(buffer, len, "GPIO%u", this->pin_); -} +size_t RP2GPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%u", this->pin_); } -bool RP2040GPIOPin::digital_read() { +bool RP2GPIOPin::digital_read() { return bool(digitalRead(pin_)) != inverted_; // NOLINT } -void RP2040GPIOPin::digital_write(bool value) { +void RP2GPIOPin::digital_write(bool value) { digitalWrite(pin_, value != inverted_ ? 1 : 0); // NOLINT } -void RP2040GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } +void RP2GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace rp2040 +} // namespace rp2 -using namespace rp2040; +using namespace rp2; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); @@ -115,4 +113,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.h b/esphome/components/rp2/gpio.h similarity index 86% rename from esphome/components/rp2040/gpio.h rename to esphome/components/rp2/gpio.h index da97cff9b1..538fef619a 100644 --- a/esphome/components/rp2040/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -1,13 +1,13 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include "esphome/core/hal.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040GPIOPin : public InternalGPIOPin { +class RP2GPIOPin final : public InternalGPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -32,6 +32,6 @@ class RP2040GPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/gpio.py b/esphome/components/rp2/gpio.py similarity index 82% rename from esphome/components/rp2040/gpio.py rename to esphome/components/rp2/gpio.py index 18fb09f76a..e4db6a831c 100644 --- a/esphome/components/rp2040/gpio.py +++ b/esphome/components/rp2/gpio.py @@ -16,22 +16,22 @@ from esphome.const import ( from esphome.core import CORE from . import boards -from .const import KEY_BOARD, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_RP2, rp2_ns -RP2040GPIOPin = rp2040_ns.class_("RP2040GPIOPin", cg.InternalGPIOPin) +RP2GPIOPin = rp2_ns.class_("RP2GPIOPin", cg.InternalGPIOPin) def _lookup_pin(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] - board_pins = boards.RP2040_BOARD_PINS.get(board, {}) + board = CORE.data[KEY_RP2][KEY_BOARD] + board_pins = boards.RP2_BOARD_PINS.get(board, {}) while isinstance(board_pins, str): - board_pins = boards.RP2040_BOARD_PINS[board_pins] + board_pins = boards.RP2_BOARD_PINS[board_pins] if value in board_pins: return board_pins[value] - if value in boards.RP2040_BASE_PINS: - return boards.RP2040_BASE_PINS[value] + if value in boards.RP2_BASE_PINS: + return boards.RP2_BASE_PINS[value] raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") @@ -61,7 +61,7 @@ def _board_max_virtual_pin(board): def validate_gpio_pin(value): value = _translate_pin(value) - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] max_virtual = _board_max_virtual_pin(board) if max_virtual is not None and boards.CYW43_GPIO_OFFSET <= value <= max_virtual: return value @@ -72,7 +72,7 @@ def validate_gpio_pin(value): def validate_supports(value): - board = CORE.data[KEY_RP2040][KEY_BOARD] + board = CORE.data[KEY_RP2][KEY_BOARD] if ( _board_max_virtual_pin(board) is None or value[CONF_NUMBER] < boards.CYW43_GPIO_OFFSET @@ -89,9 +89,9 @@ def validate_supports(value): return value -RP2040_PIN_SCHEMA = cv.All( +RP2_PIN_SCHEMA = cv.All( pins.gpio_base_schema( - RP2040GPIOPin, + RP2GPIOPin, validate_gpio_pin, modes=pins.GPIO_STANDARD_MODES + (CONF_ANALOG,), ), @@ -99,8 +99,8 @@ RP2040_PIN_SCHEMA = cv.All( ) -@pins.PIN_SCHEMA_REGISTRY.register("rp2040", RP2040_PIN_SCHEMA) -async def rp2040_pin_to_code(config): +@pins.PIN_SCHEMA_REGISTRY.register("rp2", RP2_PIN_SCHEMA) +async def rp2_pin_to_code(config): var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/rp2040/hal.cpp b/esphome/components/rp2/hal.cpp similarity index 58% rename from esphome/components/rp2040/hal.cpp rename to esphome/components/rp2/hal.cpp index e71d3fd54d..28535cacbb 100644 --- a/esphome/components/rp2040/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -1,23 +1,23 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "core.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" -#ifdef USE_RP2040_CRASH_HANDLER +#ifdef USE_RP2_CRASH_HANDLER #include "crash_handler.h" #endif #include "hardware/watchdog.h" -// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check. +// Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. // HAL functions live in namespace esphome (root) — they are not part of the -// rp2040 component's API. -namespace esphome::rp2040 {} // namespace esphome::rp2040 +// rp2 component's API. +namespace esphome::rp2 {} // namespace esphome::rp2 namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), -// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h. +// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2/hal.h. void arch_restart() { watchdog_reboot(0, 0, 10); while (1) { @@ -26,11 +26,11 @@ void arch_restart() { } void arch_init() { -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_read_and_clear(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_read_and_clear(); #endif -#if USE_RP2040_WATCHDOG_TIMEOUT > 0 - watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false); +#if USE_RP2_WATCHDOG_TIMEOUT > 0 + watchdog_enable(USE_RP2_WATCHDOG_TIMEOUT, false); #endif } @@ -38,4 +38,4 @@ uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/hal.h b/esphome/components/rp2/hal.h similarity index 96% rename from esphome/components/rp2040/hal.h rename to esphome/components/rp2/hal.h index c9c61c921d..b16f31d797 100644 --- a/esphome/components/rp2040/hal.h +++ b/esphome/components/rp2/hal.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -25,7 +25,7 @@ extern "C" uint64_t time_us_64(void); extern "C" void watchdog_update(void); extern "C" unsigned long ulMainGetRunTimeCounterValue(void); -namespace esphome::rp2040 {} +namespace esphome::rp2 {} namespace esphome { @@ -58,4 +58,4 @@ uint32_t arch_get_cpu_freq_hz(); } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/helpers.cpp b/esphome/components/rp2/helpers.cpp similarity index 98% rename from esphome/components/rp2040/helpers.cpp rename to esphome/components/rp2/helpers.cpp index 6e5ddad236..a54bcf80f7 100644 --- a/esphome/components/rp2040/helpers.cpp +++ b/esphome/components/rp2/helpers.cpp @@ -1,7 +1,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -89,4 +89,4 @@ void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parame } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/inject_lwip_include.py.script b/esphome/components/rp2/inject_lwip_include.py.script similarity index 100% rename from esphome/components/rp2040/inject_lwip_include.py.script rename to esphome/components/rp2/inject_lwip_include.py.script diff --git a/esphome/components/rp2040/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja similarity index 100% rename from esphome/components/rp2040/lwipopts.h.jinja rename to esphome/components/rp2/lwipopts.h.jinja diff --git a/esphome/components/rp2040/post_build.py.script b/esphome/components/rp2/post_build.py.script similarity index 100% rename from esphome/components/rp2040/post_build.py.script rename to esphome/components/rp2/post_build.py.script diff --git a/esphome/components/rp2/preference_backend.h b/esphome/components/rp2/preference_backend.h new file mode 100644 index 0000000000..c5e8a757da --- /dev/null +++ b/esphome/components/rp2/preference_backend.h @@ -0,0 +1,27 @@ +#pragma once +#ifdef USE_RP2 + +#include +#include + +namespace esphome::rp2 { + +class RP2PreferenceBackend final { + public: + bool save(const uint8_t *data, size_t len); + bool load(uint8_t *data, size_t len); + + size_t offset = 0; + uint32_t type = 0; +}; + +class RP2Preferences; +RP2Preferences *get_preferences(); + +} // namespace esphome::rp2 + +namespace esphome { +using PreferenceBackend = rp2::RP2PreferenceBackend; +} // namespace esphome + +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.cpp b/esphome/components/rp2/preferences.cpp similarity index 82% rename from esphome/components/rp2040/preferences.cpp rename to esphome/components/rp2/preferences.cpp index cfc802b28f..778ce070a9 100644 --- a/esphome/components/rp2040/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include @@ -12,7 +12,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { static const char *const TAG = "preferences"; @@ -37,7 +37,7 @@ template uint8_t calculate_crc(It first, It last, uint32_t type) { return crc; } -bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { +bool RP2PreferenceBackend::save(const uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -58,7 +58,7 @@ bool RP2040PreferenceBackend::save(const uint8_t *data, size_t len) { return true; } -bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { +bool RP2PreferenceBackend::load(uint8_t *data, size_t len) { const size_t buffer_size = len + 1; if (buffer_size > PREF_MAX_BUFFER_SIZE) return false; @@ -80,27 +80,27 @@ bool RP2040PreferenceBackend::load(uint8_t *data, size_t len) { return true; } -RP2040Preferences::RP2040Preferences() : eeprom_sector_(&_EEPROM_start) {} +RP2Preferences::RP2Preferences() : eeprom_sector_(&_EEPROM_start) {} -void RP2040Preferences::setup() { +void RP2Preferences::setup() { ESP_LOGVV(TAG, "Loading preferences from flash"); memcpy(s_flash_storage, this->eeprom_sector_, RP2040_FLASH_STORAGE_SIZE); } -ESPPreferenceObject RP2040Preferences::make_preference(size_t length, uint32_t type) { +ESPPreferenceObject RP2Preferences::make_preference(size_t length, uint32_t type) { uint32_t start = this->current_flash_offset; uint32_t end = start + length + 1; if (end > RP2040_FLASH_STORAGE_SIZE) { return {}; } - auto *pref = new RP2040PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) + auto *pref = new RP2PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) pref->offset = start; pref->type = type; this->current_flash_offset = end; return ESPPreferenceObject(pref); } -bool RP2040Preferences::sync() { +bool RP2Preferences::sync() { if (!s_flash_dirty) return true; if (s_prevent_write) @@ -121,7 +121,7 @@ bool RP2040Preferences::sync() { return true; } -bool RP2040Preferences::reset() { +bool RP2Preferences::reset() { ESP_LOGD(TAG, "Erasing storage"); { InterruptLock lock; @@ -133,9 +133,9 @@ bool RP2040Preferences::reset() { return true; } -static RP2040Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static RP2Preferences s_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -RP2040Preferences *get_preferences() { return &s_preferences; } +RP2Preferences *get_preferences() { return &s_preferences; } void setup_preferences() { s_preferences.setup(); @@ -143,10 +143,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esphome::rp2040 +} // namespace esphome::rp2 namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/preferences.h b/esphome/components/rp2/preferences.h similarity index 59% rename from esphome/components/rp2040/preferences.h rename to esphome/components/rp2/preferences.h index eb8c3e5f64..95f7263883 100644 --- a/esphome/components/rp2040/preferences.h +++ b/esphome/components/rp2/preferences.h @@ -1,14 +1,14 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/preference_backend.h" -namespace esphome::rp2040 { +namespace esphome::rp2 { -class RP2040Preferences final : public PreferencesMixin { +class RP2Preferences final : public PreferencesMixin { public: - using PreferencesMixin::make_preference; - RP2040Preferences(); + using PreferencesMixin::make_preference; + RP2Preferences(); void setup(); ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash) { return this->make_preference(length, type); @@ -26,8 +26,8 @@ class RP2040Preferences final : public PreferencesMixin { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esphome::rp2040 +} // namespace esphome::rp2 -DECLARE_PREFERENCE_ALIASES(esphome::rp2040::RP2040Preferences) +DECLARE_PREFERENCE_ALIASES(esphome::rp2::RP2Preferences) -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040/printf_stubs.cpp b/esphome/components/rp2/printf_stubs.cpp similarity index 94% rename from esphome/components/rp2040/printf_stubs.cpp rename to esphome/components/rp2/printf_stubs.cpp index c2174a1dec..bf03565f30 100644 --- a/esphome/components/rp2040/printf_stubs.cpp +++ b/esphome/components/rp2/printf_stubs.cpp @@ -13,12 +13,12 @@ * Saves ~8.9 KB of flash. */ -#if defined(USE_RP2040) && !defined(USE_FULL_PRINTF) +#if defined(USE_RP2) && !defined(USE_FULL_PRINTF) #include #include #include -namespace esphome::rp2040 {} +namespace esphome::rp2 {} static constexpr size_t PRINTF_BUFFER_SIZE = 512; @@ -71,4 +71,4 @@ int __wrap_fprintf(FILE *stream, const char *fmt, ...) { } // extern "C" // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) -#endif // USE_RP2040 && !USE_FULL_PRINTF +#endif // USE_RP2 && !USE_FULL_PRINTF diff --git a/esphome/components/rp2040/core.cpp b/esphome/components/rp2040/core.cpp deleted file mode 100644 index 11f23ccfef..0000000000 --- a/esphome/components/rp2040/core.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#ifdef USE_RP2040 - -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// rp2040 — there is no extra component bootstrap to keep here. - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040/preference_backend.h b/esphome/components/rp2040/preference_backend.h deleted file mode 100644 index 790ee8831d..0000000000 --- a/esphome/components/rp2040/preference_backend.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_RP2040 - -#include -#include - -namespace esphome::rp2040 { - -class RP2040PreferenceBackend final { - public: - bool save(const uint8_t *data, size_t len); - bool load(uint8_t *data, size_t len); - - size_t offset = 0; - uint32_t type = 0; -}; - -class RP2040Preferences; -RP2040Preferences *get_preferences(); - -} // namespace esphome::rp2040 - -namespace esphome { -using PreferenceBackend = rp2040::RP2040PreferenceBackend; -} // namespace esphome - -#endif // USE_RP2040 diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index 648f22691c..ac012b5e85 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID from esphome.types import ConfigType -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 24b3860cc1..885e49f690 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -18,7 +18,7 @@ enum class BLEComponentState : uint8_t { DISABLED, }; -class RP2040BLE : public Component { +class RP2040BLE final : public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 8afba6ba1d..b9c0a9c257 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -1,6 +1,6 @@ #include "led_strip.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/helpers.h" #include "esphome/core/log.h" diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index ebc3bbbaa5..b74dd14108 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -57,7 +57,7 @@ inline const char *rgb_order_to_string(RGBOrder order) { using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); -class RP2040PIOLEDStripLightOutput : public light::AddressableLight { +class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; @@ -128,4 +128,4 @@ class RP2040PIOLEDStripLightOutput : public light::AddressableLight { } // namespace esphome::rp2040_pio_led_strip -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py index 274f059bd5..b3f816102a 100644 --- a/esphome/components/rp2040_pio_led_strip/light.py +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg -from esphome.components import light, rp2040 +from esphome.components import light, rp2 import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -130,9 +130,9 @@ def time_to_cycles(time_us): CONF_PIO = "pio" -AUTO_LOAD = ["rp2040_pio"] +AUTO_LOAD = ["rp2_pio"] CODEOWNERS = ["@Papa-DMan"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pio_led_strip_ns = cg.esphome_ns.namespace("rp2040_pio_led_strip") RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( @@ -250,7 +250,7 @@ async def to_code(config): if chipset := config.get(CONF_CHIPSET): cg.add(var.set_chipset(chipset)) _LOGGER.info("Generating PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( @@ -265,7 +265,7 @@ async def to_code(config): else: cg.add(var.set_chipset(Chipset.CHIPSET_CUSTOM)) _LOGGER.info("Generating custom PIO assembly code") - rp2040.add_pio_file( + rp2.add_pio_file( __name__, key, generate_assembly_code( diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index ad37926954..a2fda58c9e 100644 --- a/esphome/components/rp2040_pwm/output.py +++ b/esphome/components/rp2040_pwm/output.py @@ -5,7 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm") diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index c9b9e6739d..270cc33551 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -1,4 +1,4 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "rp2040_pwm.h" #include "esphome/core/defines.h" diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.h b/esphome/components/rp2040_pwm/rp2040_pwm.h index 58d3955a31..8263113168 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.h +++ b/esphome/components/rp2040_pwm/rp2040_pwm.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/components/output/float_output.h" #include "esphome/core/automation.h" @@ -9,7 +9,7 @@ namespace esphome::rp2040_pwm { -class RP2040PWM : public output::FloatOutput, public Component { +class RP2040PWM final : public output::FloatOutput, public Component { public: void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } @@ -39,7 +39,7 @@ class RP2040PWM : public output::FloatOutput, public Component { bool frequency_changed_{false}; }; -template class SetFrequencyAction : public Action { +template class SetFrequencyAction final : public Action { public: SetFrequencyAction(RP2040PWM *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, frequency); @@ -54,4 +54,4 @@ template class SetFrequencyAction : public Action { } // namespace esphome::rp2040_pwm -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/rp2040_pio/__init__.py b/esphome/components/rp2_pio/__init__.py similarity index 98% rename from esphome/components/rp2040_pio/__init__.py rename to esphome/components/rp2_pio/__init__.py index eecfedaa75..9046d2ae6b 100644 --- a/esphome/components/rp2040_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -3,7 +3,7 @@ import platform import esphome.codegen as cg import esphome.config_validation as cv -DEPENDENCIES = ["rp2040"] +DEPENDENCIES = ["rp2"] PIOASM_REPO_VERSION = "1.5.0-b" diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index 8b1457926c..df0e2b0b16 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -18,7 +18,7 @@ namespace esphome::rpi_dpi_rgb { constexpr static const char *const TAG = "rpi_dpi_rgb"; -class RpiDpiRgb : public display::Display { +class RpiDpiRgb final : public display::Display { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/rtl87xx/boards.py b/esphome/components/rtl87xx/boards.py index 3a5ee853f2..23d220a91e 100644 --- a/esphome/components/rtl87xx/boards.py +++ b/esphome/components/rtl87xx/boards.py @@ -15,40 +15,24 @@ Any manual changes WILL BE LOST on regeneration. from esphome.components.libretiny.const import FAMILY_RTL8710B, FAMILY_RTL8720C RTL87XX_BOARDS = { - "wr3le": { - "name": "WR3LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr2": { - "name": "WR2 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbr3": { - "name": "WBR3 Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8710bn-2mb-468k": { - "name": "Generic - RTL8710BN (2M/468k)", - "family": FAMILY_RTL8710B, - }, - "wr1e": { - "name": "WR1E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3e": { - "name": "WR3E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wr3": { - "name": "WR3 Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, "afw121t": { "name": "AFW121T", "family": FAMILY_RTL8710B, }, - "wr3n": { - "name": "WR3N Wi-Fi Module", + "bw12": { + "name": "BW12", + "family": FAMILY_RTL8710B, + }, + "bw15": { + "name": "BW15", + "family": FAMILY_RTL8720C, + }, + "cr3l": { + "name": "CR3L Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "generic-rtl8710bn-2mb-468k": { + "name": "Generic - RTL8710BN (2M/468k)", "family": FAMILY_RTL8710B, }, "generic-rtl8710bn-2mb-788k": { @@ -59,42 +43,6 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8710BX (4M/980k)", "family": FAMILY_RTL8710B, }, - "wr2e": { - "name": "WR2E Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "t112-v1.1": { - "name": "T112_V1.1", - "family": FAMILY_RTL8710B, - }, - "wr3l": { - "name": "WR3L Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "wbru": { - "name": "WBRU Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "wr2le": { - "name": "WR2LE Wi-Fi Module", - "family": FAMILY_RTL8710B, - }, - "bw15": { - "name": "BW15", - "family": FAMILY_RTL8720C, - }, - "t103-v1.0": { - "name": "T103_V1.0", - "family": FAMILY_RTL8710B, - }, - "cr3l": { - "name": "CR3L Wi-Fi Module", - "family": FAMILY_RTL8720C, - }, - "generic-rtl8720cm-4mb-1712k": { - "name": "Generic - RTL8720CM (4M/1712k)", - "family": FAMILY_RTL8720C, - }, "generic-rtl8720cf-2mb-896k": { "name": "Generic - RTL8720CF (2M/896k)", "family": FAMILY_RTL8720C, @@ -103,521 +51,81 @@ RTL87XX_BOARDS = { "name": "Generic - RTL8720CF (2M/992k)", "family": FAMILY_RTL8720C, }, - "bw12": { - "name": "BW12", - "family": FAMILY_RTL8710B, + "generic-rtl8720cm-4mb-1712k": { + "name": "Generic - RTL8720CM (4M/1712k)", + "family": FAMILY_RTL8720C, }, "t102-v1.1": { "name": "T102_V1.1", "family": FAMILY_RTL8710B, }, - "wr2l": { - "name": "WR2L Wi-Fi Module", + "t103-v1.0": { + "name": "T103_V1.0", + "family": FAMILY_RTL8710B, + }, + "t112-v1.1": { + "name": "T112_V1.1", "family": FAMILY_RTL8710B, }, "wbr1": { "name": "WBR1 Wi-Fi Module", "family": FAMILY_RTL8720C, }, + "wbr3": { + "name": "WBR3 Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, + "wbru": { + "name": "WBRU Wi-Fi Module", + "family": FAMILY_RTL8720C, + }, "wr1": { "name": "WR1 Wi-Fi Module", "family": FAMILY_RTL8710B, }, + "wr1e": { + "name": "WR1E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2": { + "name": "WR2 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2e": { + "name": "WR2E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2l": { + "name": "WR2L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr2le": { + "name": "WR2LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3": { + "name": "WR3 Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3e": { + "name": "WR3E Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3l": { + "name": "WR3L Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3le": { + "name": "WR3LE Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, + "wr3n": { + "name": "WR3N Wi-Fi Module", + "family": FAMILY_RTL8710B, + }, } RTL87XX_BOARD_PINS = { - "wr3le": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr2": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC2": 41, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D4": 18, - "D5": 23, - "D6": 14, - "D7": 15, - "D8": 30, - "D9": 29, - "A1": 41, - }, - "wbr3": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS1": 4, - "CTS2": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PWM5": 17, - "PWM6": 18, - "RX2": 15, - "SDA0": 16, - "TX2": 16, - "D0": 7, - "D1": 11, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 12, - "D6": 16, - "D7": 17, - "D8": 18, - "D9": 19, - "D10": 13, - "D11": 14, - "D12": 15, - "D13": 0, - "D14": 1, - }, - "generic-rtl8710bn-2mb-468k": { - "SPI0_CS": 19, - "SPI0_FCS": 6, - "SPI0_FD0": 9, - "SPI0_FD1": 7, - "SPI0_FD2": 8, - "SPI0_FD3": 11, - "SPI0_FSCK": 10, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "FCS": 6, - "FD0": 9, - "FD1": 7, - "FD2": 8, - "FD3": 11, - "FSCK": 10, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA06": 6, - "PA6": 6, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 0, - "D1": 5, - "D2": 6, - "D3": 7, - "D4": 8, - "D5": 9, - "D6": 10, - "D7": 11, - "D8": 12, - "D9": 14, - "D10": 15, - "D11": 18, - "D12": 19, - "D13": 22, - "D14": 23, - "D15": 29, - "D16": 30, - "A0": 19, - "A1": 41, - }, - "wr1e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM3": 12, - "PWM4": 29, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 23, - "D1": 18, - "D2": 14, - "D3": 15, - "D4": 30, - "D5": 12, - "D6": 5, - "D7": 29, - "D8": 19, - "D9": 22, - "A0": 19, - "A1": 41, - }, - "wr3e": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 22, - "D4": 0, - "D5": 30, - "D6": 19, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "wr3": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, "afw121t": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -686,16 +194,33 @@ RTL87XX_BOARD_PINS = { "D9": 23, "D10": 30, }, - "wr3n": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, + "bw12": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, "SERIAL2_TX": 30, - "ADC2": 41, + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, "PA00": 0, @@ -706,32 +231,269 @@ RTL87XX_BOARD_PINS = { "PA14": 14, "PA15": 15, "PA18": 18, + "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, "PWM2": 0, "PWM3": 12, - "PWM4": 5, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, - "SDA0": 30, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 29, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 30, - "D5": 5, - "D6": 12, + "D0": 5, + "D1": 29, + "D2": 0, + "D3": 19, + "D4": 22, + "D5": 30, + "D6": 14, + "D7": 12, + "D8": 15, + "D9": 18, + "D10": 23, + "A0": 19, + }, + "bw15": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM1": 1, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX2": 15, + "SCL0": 19, + "SDA0": 3, + "TX0": 14, + "TX2": 16, + "D0": 17, + "D1": 18, + "D2": 2, + "D3": 15, + "D4": 4, + "D5": 19, + "D6": 20, + "D7": 16, + "D8": 0, + "D9": 3, + "D10": 1, + "D11": 13, + "D12": 14, + }, + "cr3l": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 15, + "SPI0_MISO": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 15, + "WIRE0_SCL_2": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 16, + "WIRE0_SDA_2": 20, + "SERIAL0_RX": 13, + "SERIAL0_TX": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX": 2, + "SERIAL1_TX": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "CTS2": 19, + "MISO0": 20, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "RTS2": 20, + "RX0": 13, + "RX1": 2, + "RX2": 15, + "SCL0": 19, + "SDA0": 16, + "TX0": 14, + "TX1": 3, + "TX2": 16, + "D0": 20, + "D1": 2, + "D2": 3, + "D3": 4, + "D4": 15, + "D5": 16, + "D6": 17, "D7": 18, - "D8": 23, + "D8": 19, + "D9": 13, + "D10": 14, + }, + "generic-rtl8710bn-2mb-468k": { + "SPI0_CS": 19, + "SPI0_FCS": 6, + "SPI0_FD0": 9, + "SPI0_FD1": 7, + "SPI0_FD2": 8, + "SPI0_FD3": 11, + "SPI0_FSCK": 10, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "FCS": 6, + "FD0": 9, + "FD1": 7, + "FD2": 8, + "FD3": 11, + "FSCK": 10, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA06": 6, + "PA6": 6, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 30, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 0, + "D1": 5, + "D2": 6, + "D3": 7, + "D4": 8, + "D5": 9, + "D6": 10, + "D7": 11, + "D8": 12, + "D9": 14, + "D10": 15, + "D11": 18, + "D12": 19, + "D13": 22, + "D14": 23, + "D15": 29, + "D16": 30, + "A0": 19, "A1": 41, }, "generic-rtl8710bn-2mb-788k": { @@ -930,13 +692,363 @@ RTL87XX_BOARD_PINS = { "D16": 30, "A0": 19, }, - "wr2e": { + "generic-rtl8720cf-2mb-896k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cf-2mb-992k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "generic-rtl8720cm-4mb-1712k": { + "SPI0_CS_0": 2, + "SPI0_CS_1": 7, + "SPI0_CS_2": 15, + "SPI0_MISO_0": 10, + "SPI0_MISO_1": 20, + "SPI0_MOSI_0": 4, + "SPI0_MOSI_1": 9, + "SPI0_MOSI_2": 19, + "SPI0_SCK_0": 3, + "SPI0_SCK_1": 8, + "SPI0_SCK_2": 16, + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "WIRE0_SDA_3": 20, + "SERIAL0_CTS": 10, + "SERIAL0_RTS": 9, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RTS": 20, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS0": 10, + "CTS1": 4, + "CTS2": 19, + "MOSI0": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA08": 8, + "PA8": 8, + "PA09": 9, + "PA9": 9, + "PA10": 10, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PA19": 19, + "PA20": 20, + "PA23": 23, + "PWM0": 20, + "PWM5": 17, + "PWM6": 18, + "PWM7": 23, + "RTS0": 9, + "RTS2": 20, + "RX2": 15, + "SCK0": 16, + "TX2": 16, + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 7, + "D6": 8, + "D7": 9, + "D8": 10, + "D9": 11, + "D10": 12, + "D11": 13, + "D12": 14, + "D13": 15, + "D14": 16, + "D15": 17, + "D16": 18, + "D17": 19, + "D18": 20, + "D19": 23, + }, + "t102-v1.1": { "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D3": 30, + "D4": 29, + "D5": 18, + "D6": 23, + "D7": 14, + "D8": 15, + }, + "t103-v1.0": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, "WIRE0_SDA_0": 19, "WIRE0_SDA_1": 30, "WIRE1_SCL": 18, "WIRE1_SDA": 23, "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, "SERIAL0_RX": 18, "SERIAL0_TX": 23, "SERIAL2_RX": 29, @@ -946,8 +1058,12 @@ RTL87XX_BOARD_PINS = { "CS0": 19, "CS1": 19, "CTS0": 19, + "MISO0": 22, + "MISO1": 22, "MOSI0": 23, "MOSI1": 23, + "PA00": 0, + "PA0": 0, "PA05": 5, "PA5": 5, "PA12": 12, @@ -955,30 +1071,35 @@ RTL87XX_BOARD_PINS = { "PA15": 15, "PA18": 18, "PA19": 19, + "PA22": 22, "PA23": 23, "PA29": 29, "PA30": 30, "PWM1": 15, + "PWM2": 0, "PWM3": 12, - "PWM4": 29, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, "RX0": 18, "RX2": 29, "SCK0": 18, "SCK1": 18, - "SCL0": 29, "SCL1": 18, "SDA1": 23, "TX0": 23, "TX2": 30, - "D0": 12, - "D1": 19, - "D2": 5, - "D3": 18, - "D4": 23, - "D5": 14, - "D6": 15, - "D7": 30, - "D8": 29, + "D0": 19, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 22, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, "A0": 19, "A1": 41, }, @@ -1051,76 +1172,129 @@ RTL87XX_BOARD_PINS = { "D10": 30, "A0": 19, }, - "wr3l": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, + "wbr1": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CTS1": 4, + "MOSI0": 4, "PA00": 0, "PA0": 0, - "PA05": 5, - "PA5": 5, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA11": 11, "PA12": 12, + "PA13": 13, "PA14": 14, "PA15": 15, + "PA16": 16, + "PA17": 17, + "PA18": 18, + "PWM5": 17, + "PWM6": 18, + "PWM7": 13, + "RX2": 15, + "SCL0": 15, + "SDA0": 12, + "TX2": 16, + "D0": 14, + "D1": 13, + "D2": 2, + "D3": 3, + "D4": 16, + "D5": 4, + "D6": 11, + "D7": 15, + "D8": 12, + "D9": 17, + "D10": 18, + "D11": 0, + "D12": 1, + }, + "wbr3": { + "WIRE0_SCL_0": 2, + "WIRE0_SCL_1": 11, + "WIRE0_SCL_2": 15, + "WIRE0_SCL_3": 19, + "WIRE0_SDA_0": 3, + "WIRE0_SDA_1": 12, + "WIRE0_SDA_2": 16, + "SERIAL0_RX_0": 12, + "SERIAL0_RX_1": 13, + "SERIAL0_TX_0": 11, + "SERIAL0_TX_1": 14, + "SERIAL1_CTS": 4, + "SERIAL1_RX_0": 0, + "SERIAL1_RX_1": 2, + "SERIAL1_TX_0": 1, + "SERIAL1_TX_1": 3, + "SERIAL2_CTS": 19, + "SERIAL2_RX": 15, + "SERIAL2_TX": 16, + "CS0": 15, + "CTS1": 4, + "CTS2": 19, + "PA00": 0, + "PA0": 0, + "PA01": 1, + "PA1": 1, + "PA02": 2, + "PA2": 2, + "PA03": 3, + "PA3": 3, + "PA04": 4, + "PA4": 4, + "PA07": 7, + "PA7": 7, + "PA11": 11, + "PA12": 12, + "PA13": 13, + "PA14": 14, + "PA15": 15, + "PA16": 16, + "PA17": 17, "PA18": 18, "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 22, - "D1": 19, - "D2": 14, - "D3": 15, - "D4": 0, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, + "PWM5": 17, + "PWM6": 18, + "RX2": 15, + "SDA0": 16, + "TX2": 16, + "D0": 7, + "D1": 11, + "D2": 2, + "D3": 3, + "D4": 4, + "D5": 12, + "D6": 16, + "D7": 17, + "D8": 18, + "D9": 19, + "D10": 13, + "D11": 14, + "D12": 15, + "D13": 0, + "D14": 1, }, "wbru": { "SPI0_CS_0": 2, @@ -1215,724 +1389,6 @@ RTL87XX_BOARD_PINS = { "D16": 10, "D17": 7, }, - "wr2le": { - "MISO0": 22, - "MISO1": 22, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA22": 22, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "SCL0": 22, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 22, - "D4": 12, - }, - "bw15": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM1": 1, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX2": 15, - "SCL0": 19, - "SDA0": 3, - "TX0": 14, - "TX2": 16, - "D0": 17, - "D1": 18, - "D2": 2, - "D3": 15, - "D4": 4, - "D5": 19, - "D6": 20, - "D7": 16, - "D8": 0, - "D9": 3, - "D10": 1, - "D11": 13, - "D12": 14, - }, - "t103-v1.0": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "ADC2": 41, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 5, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 19, - "D1": 14, - "D2": 15, - "D3": 0, - "D4": 22, - "D5": 29, - "D6": 30, - "D7": 5, - "D8": 12, - "D9": 18, - "D10": 23, - "A0": 19, - "A1": 41, - }, - "cr3l": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 15, - "SPI0_MISO": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 15, - "WIRE0_SCL_2": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 16, - "WIRE0_SDA_2": 20, - "SERIAL0_RX": 13, - "SERIAL0_TX": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX": 2, - "SERIAL1_TX": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "CTS2": 19, - "MISO0": 20, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "RTS2": 20, - "RX0": 13, - "RX1": 2, - "RX2": 15, - "SCL0": 19, - "SDA0": 16, - "TX0": 14, - "TX1": 3, - "TX2": 16, - "D0": 20, - "D1": 2, - "D2": 3, - "D3": 4, - "D4": 15, - "D5": 16, - "D6": 17, - "D7": 18, - "D8": 19, - "D9": 13, - "D10": 14, - }, - "generic-rtl8720cm-4mb-1712k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-896k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "generic-rtl8720cf-2mb-992k": { - "SPI0_CS_0": 2, - "SPI0_CS_1": 7, - "SPI0_CS_2": 15, - "SPI0_MISO_0": 10, - "SPI0_MISO_1": 20, - "SPI0_MOSI_0": 4, - "SPI0_MOSI_1": 9, - "SPI0_MOSI_2": 19, - "SPI0_SCK_0": 3, - "SPI0_SCK_1": 8, - "SPI0_SCK_2": 16, - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SCL_3": 19, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "WIRE0_SDA_3": 20, - "SERIAL0_CTS": 10, - "SERIAL0_RTS": 9, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_CTS": 19, - "SERIAL2_RTS": 20, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CS0": 15, - "CTS0": 10, - "CTS1": 4, - "CTS2": 19, - "MOSI0": 19, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA07": 7, - "PA7": 7, - "PA08": 8, - "PA8": 8, - "PA09": 9, - "PA9": 9, - "PA10": 10, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PA19": 19, - "PA20": 20, - "PA23": 23, - "PWM0": 20, - "PWM5": 17, - "PWM6": 18, - "PWM7": 23, - "RTS0": 9, - "RTS2": 20, - "RX2": 15, - "SCK0": 16, - "TX2": 16, - "D0": 0, - "D1": 1, - "D2": 2, - "D3": 3, - "D4": 4, - "D5": 7, - "D6": 8, - "D7": 9, - "D8": 10, - "D9": 11, - "D10": 12, - "D11": 13, - "D12": 14, - "D13": 15, - "D14": 16, - "D15": 17, - "D16": 18, - "D17": 19, - "D18": 20, - "D19": 23, - }, - "bw12": { - "SPI0_CS": 19, - "SPI0_MISO": 22, - "SPI0_MOSI": 23, - "SPI0_SCK": 18, - "SPI1_CS": 19, - "SPI1_MISO": 22, - "SPI1_MOSI": 23, - "SPI1_SCK": 18, - "WIRE0_SCL_0": 22, - "WIRE0_SCL_1": 29, - "WIRE0_SDA_0": 19, - "WIRE0_SDA_1": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_CTS": 19, - "SERIAL0_RTS": 22, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "MISO0": 22, - "MISO1": 22, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA19": 19, - "PA22": 22, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 30, - "PWM5": 22, - "RTS0": 22, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL1": 18, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 5, - "D1": 29, - "D2": 0, - "D3": 19, - "D4": 22, - "D5": 30, - "D6": 14, - "D7": 12, - "D8": 15, - "D9": 18, - "D10": 23, - "A0": 19, - }, - "t102-v1.1": { - "WIRE0_SCL": 29, - "WIRE0_SDA": 30, - "WIRE1_SCL": 18, - "WIRE1_SDA": 23, - "SERIAL0_RX": 18, - "SERIAL0_TX": 23, - "SERIAL2_RX": 29, - "SERIAL2_TX": 30, - "MOSI0": 23, - "MOSI1": 23, - "PA00": 0, - "PA0": 0, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA18": 18, - "PA23": 23, - "PA29": 29, - "PA30": 30, - "PWM1": 15, - "PWM2": 0, - "PWM3": 12, - "PWM4": 29, - "RX0": 18, - "RX2": 29, - "SCK0": 18, - "SCK1": 18, - "SCL0": 29, - "SCL1": 18, - "SDA0": 30, - "SDA1": 23, - "TX0": 23, - "TX2": 30, - "D0": 12, - "D1": 0, - "D2": 5, - "D3": 30, - "D4": 29, - "D5": 18, - "D6": 23, - "D7": 14, - "D8": 15, - }, - "wr2l": { - "ADC1": 19, - "CS0": 19, - "CS1": 19, - "CTS0": 19, - "PA05": 5, - "PA5": 5, - "PA12": 12, - "PA14": 14, - "PA15": 15, - "PA19": 19, - "PWM0": 14, - "PWM1": 15, - "PWM3": 12, - "PWM4": 5, - "SDA0": 19, - "D0": 15, - "D1": 14, - "D2": 5, - "D3": 19, - "D4": 12, - "A0": 19, - }, - "wbr1": { - "WIRE0_SCL_0": 2, - "WIRE0_SCL_1": 11, - "WIRE0_SCL_2": 15, - "WIRE0_SDA_0": 3, - "WIRE0_SDA_1": 12, - "WIRE0_SDA_2": 16, - "SERIAL0_RX_0": 12, - "SERIAL0_RX_1": 13, - "SERIAL0_TX_0": 11, - "SERIAL0_TX_1": 14, - "SERIAL1_CTS": 4, - "SERIAL1_RX_0": 0, - "SERIAL1_RX_1": 2, - "SERIAL1_TX_0": 1, - "SERIAL1_TX_1": 3, - "SERIAL2_RX": 15, - "SERIAL2_TX": 16, - "CTS1": 4, - "MOSI0": 4, - "PA00": 0, - "PA0": 0, - "PA01": 1, - "PA1": 1, - "PA02": 2, - "PA2": 2, - "PA03": 3, - "PA3": 3, - "PA04": 4, - "PA4": 4, - "PA11": 11, - "PA12": 12, - "PA13": 13, - "PA14": 14, - "PA15": 15, - "PA16": 16, - "PA17": 17, - "PA18": 18, - "PWM5": 17, - "PWM6": 18, - "PWM7": 13, - "RX2": 15, - "SCL0": 15, - "SDA0": 12, - "TX2": 16, - "D0": 14, - "D1": 13, - "D2": 2, - "D3": 3, - "D4": 16, - "D5": 4, - "D6": 11, - "D7": 15, - "D8": 12, - "D9": 17, - "D10": 18, - "D11": 0, - "D12": 1, - }, "wr1": { "SPI0_CS": 19, "SPI0_MISO": 22, @@ -2001,6 +1457,550 @@ RTL87XX_BOARD_PINS = { "A0": 19, "A1": 41, }, + "wr1e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 23, + "D1": 18, + "D2": 14, + "D3": 15, + "D4": 30, + "D5": 12, + "D6": 5, + "D7": 29, + "D8": 19, + "D9": 22, + "A0": 19, + "A1": 41, + }, + "wr2": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 0, + "D2": 5, + "D4": 18, + "D5": 23, + "D6": 14, + "D7": 15, + "D8": 30, + "D9": 29, + "A1": 41, + }, + "wr2e": { + "WIRE0_SCL": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MOSI0": 23, + "MOSI1": 23, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM3": 12, + "PWM4": 29, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 12, + "D1": 19, + "D2": 5, + "D3": 18, + "D4": 23, + "D5": 14, + "D6": 15, + "D7": 30, + "D8": 29, + "A0": 19, + "A1": 41, + }, + "wr2l": { + "ADC1": 19, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA19": 19, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "SDA0": 19, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 19, + "D4": 12, + "A0": 19, + }, + "wr2le": { + "MISO0": 22, + "MISO1": 22, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA22": 22, + "PWM0": 14, + "PWM1": 15, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "SCL0": 22, + "D0": 15, + "D1": 14, + "D2": 5, + "D3": 22, + "D4": 12, + }, + "wr3": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3e": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3l": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 22, + "D1": 19, + "D2": 14, + "D3": 15, + "D4": 0, + "D5": 29, + "D6": 30, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3le": { + "SPI0_CS": 19, + "SPI0_MISO": 22, + "SPI0_MOSI": 23, + "SPI0_SCK": 18, + "SPI1_CS": 19, + "SPI1_MISO": 22, + "SPI1_MOSI": 23, + "SPI1_SCK": 18, + "WIRE0_SCL_0": 22, + "WIRE0_SCL_1": 29, + "WIRE0_SDA_0": 19, + "WIRE0_SDA_1": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_CTS": 19, + "SERIAL0_RTS": 22, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC1": 19, + "ADC2": 41, + "CS0": 19, + "CS1": 19, + "CTS0": 19, + "MISO0": 22, + "MISO1": 22, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA19": 19, + "PA22": 22, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "PWM5": 22, + "RTS0": 22, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL1": 18, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 22, + "D4": 0, + "D5": 30, + "D6": 19, + "D7": 5, + "D8": 12, + "D9": 18, + "D10": 23, + "A0": 19, + "A1": 41, + }, + "wr3n": { + "WIRE0_SCL": 29, + "WIRE0_SDA": 30, + "WIRE1_SCL": 18, + "WIRE1_SDA": 23, + "SERIAL0_RX": 18, + "SERIAL0_TX": 23, + "SERIAL2_RX": 29, + "SERIAL2_TX": 30, + "ADC2": 41, + "MOSI0": 23, + "MOSI1": 23, + "PA00": 0, + "PA0": 0, + "PA05": 5, + "PA5": 5, + "PA12": 12, + "PA14": 14, + "PA15": 15, + "PA18": 18, + "PA23": 23, + "PA29": 29, + "PA30": 30, + "PWM1": 15, + "PWM2": 0, + "PWM3": 12, + "PWM4": 5, + "RX0": 18, + "RX2": 29, + "SCK0": 18, + "SCK1": 18, + "SCL0": 29, + "SCL1": 18, + "SDA0": 30, + "SDA1": 23, + "TX0": 23, + "TX2": 30, + "D0": 29, + "D1": 14, + "D2": 15, + "D3": 0, + "D4": 30, + "D5": 5, + "D6": 12, + "D7": 18, + "D8": 23, + "A1": 41, + }, } BOARDS = RTL87XX_BOARDS diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index d060b6b024..256bdce5f2 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -27,7 +27,7 @@ enum class State : uint8_t { STOPPING, }; -class Rtttl : public Component { +class Rtttl final : public Component { public: #ifdef USE_OUTPUT void set_output(output::FloatOutput *output) { this->output_ = output; } @@ -116,7 +116,7 @@ class Rtttl : public Component { #endif }; -template class PlayAction : public Action { +template class PlayAction final : public Action { public: PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} TEMPLATABLE_VALUE(std::string, value) @@ -127,12 +127,12 @@ template class PlayAction : public Action { Rtttl *rtttl_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_playing(); } }; 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.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index d733394b78..12e4d14ba2 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -95,7 +95,7 @@ void RuntimeStatsCollector::log_stats_() { ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, - stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0f); } } diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 1e4910453a..2c783a0df3 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -17,7 +17,7 @@ namespace runtime_stats { static const char *const TAG = "runtime_stats"; -class RuntimeStatsCollector { +class RuntimeStatsCollector final { public: RuntimeStatsCollector(); diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.h b/esphome/components/ruuvi_ble/ruuvi_ble.h index 80b07d410b..e372b24944 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.h +++ b/esphome/components/ruuvi_ble/ruuvi_ble.h @@ -25,7 +25,7 @@ bool parse_ruuvi_data_byte(uint8_t data_type, const uint8_t *data, uint8_t data_ optional parse_ruuvi(const esp32_ble_tracker::ESPBTDevice &device); -class RuuviListener : public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/ruuvitag/ruuvitag.h b/esphome/components/ruuvitag/ruuvitag.h index 259675835d..9602b82afc 100644 --- a/esphome/components/ruuvitag/ruuvitag.h +++ b/esphome/components/ruuvitag/ruuvitag.h @@ -9,7 +9,7 @@ namespace esphome::ruuvitag { -class RuuviTag : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class RuuviTag final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h index 152bd10f27..0c738a9b78 100644 --- a/esphome/components/rx8130/rx8130.h +++ b/esphome/components/rx8130/rx8130.h @@ -6,7 +6,7 @@ namespace esphome::rx8130 { -class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { +class RX8130Component final : public time::RealTimeClock, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -18,12 +18,12 @@ class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { void stop_(bool stop); }; -template class WriteAction : public Action, public Parented { +template class WriteAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->write_time(); } }; -template class ReadAction : public Action, public Parented { +template class ReadAction final : public Action, public Parented { public: void play(const Ts... x) override { this->parent_->read_time(); } }; diff --git a/esphome/components/safe_mode/__init__.py b/esphome/components/safe_mode/__init__.py index 578376258a..c11447e604 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, preferences import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -7,6 +7,7 @@ from esphome.const import ( CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, + CONF_STORAGE, KEY_PAST_SAFE_MODE, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -42,6 +43,7 @@ CONFIG_SCHEMA = cv.All( CONF_REBOOT_TIMEOUT, default="5min" ): cv.positive_time_period_milliseconds, cv.Optional(CONF_ON_SAFE_MODE): automation.validate_automation({}), + **preferences.storage_schema(), } ).extend(cv.COMPONENT_SCHEMA), _remove_id_if_disabled, @@ -87,6 +89,7 @@ async def to_code(config): config[CONF_NUM_ATTEMPTS], config[CONF_REBOOT_TIMEOUT], config[CONF_BOOT_IS_GOOD_AFTER], + preferences.is_in_flash(config[CONF_STORAGE]), ) cg.add(RawExpression(f"if ({condition}) return")) diff --git a/esphome/components/safe_mode/automation.h b/esphome/components/safe_mode/automation.h index 79b53c0881..e2858dff34 100644 --- a/esphome/components/safe_mode/automation.h +++ b/esphome/components/safe_mode/automation.h @@ -4,7 +4,7 @@ namespace esphome::safe_mode { -template class MarkSuccessfulAction : public Action, public Parented { +template class MarkSuccessfulAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->mark_successful(); } }; diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 5c0047dca0..2eb1085ee5 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -162,13 +162,13 @@ bool SafeModeComponent::get_safe_mode_pending() { return this->read_rtc_() == SafeModeComponent::ENTER_SAFE_MODE_MAGIC; } -bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, - uint32_t boot_is_good_after) { +bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, + bool in_flash) { this->safe_mode_start_time_ = millis(); this->safe_mode_enable_time_ = enable_time; this->safe_mode_boot_is_good_after_ = boot_is_good_after; this->safe_mode_num_attempts_ = num_attempts; - this->rtc_ = global_preferences->make_preference(RTC_KEY, false); + this->rtc_ = global_preferences->make_preference(RTC_KEY, in_flash); #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) // Check partition state to detect if bootloader supports rollback diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 94db4357eb..d81b8a42d1 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,7 +17,7 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: - bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after); + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode void set_safe_mode_pending(const bool &pending); diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.h b/esphome/components/safe_mode/switch/safe_mode_switch.h index c73a2087d7..cbd79cd520 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.h +++ b/esphome/components/safe_mode/switch/safe_mode_switch.h @@ -6,7 +6,7 @@ namespace esphome::safe_mode { -class SafeModeSwitch : public switch_::Switch, public Component { +class SafeModeSwitch final : public switch_::Switch, public Component { public: void dump_config() override; void set_safe_mode(SafeModeComponent *safe_mode_component); diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h index 1f04739893..a816ae1f26 100644 --- a/esphome/components/scd30/automation.h +++ b/esphome/components/scd30/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd30 { -template class ForceRecalibrationWithReference : public Action, public Parented { +template +class ForceRecalibrationWithReference final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { diff --git a/esphome/components/scd30/scd30.h b/esphome/components/scd30/scd30.h index a5a5df1903..0605ab4175 100644 --- a/esphome/components/scd30/scd30.h +++ b/esphome/components/scd30/scd30.h @@ -7,7 +7,7 @@ namespace esphome::scd30 { /// This class implements support for the Sensirion scd30 i2c GAS (VOC and CO2eq) sensors. -class SCD30Component : public Component, public sensirion_common::SensirionI2CDevice { +class SCD30Component final : public Component, public sensirion_common::SensirionI2CDevice { public: void set_co2_sensor(sensor::Sensor *co2) { co2_sensor_ = co2; } void set_humidity_sensor(sensor::Sensor *humidity) { humidity_sensor_ = humidity; } diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h index e485289c95..4746c0c879 100644 --- a/esphome/components/scd4x/automation.h +++ b/esphome/components/scd4x/automation.h @@ -6,7 +6,8 @@ namespace esphome::scd4x { -template class PerformForcedCalibrationAction : public Action, public Parented { +template +class PerformForcedCalibrationAction final : public Action, public Parented { public: void play(const Ts &...x) override { if (this->value_.has_value()) { @@ -18,7 +19,7 @@ template class PerformForcedCalibrationAction : public Action class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 3e4827ef14..4d5dedb5e9 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -22,7 +22,7 @@ enum MeasurementMode : uint8_t { SINGLE_SHOT_RHT_ONLY, }; -class SCD4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SCD4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 6cd33e566c..790ac107c5 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -216,7 +216,7 @@ template class ParallelScript : public Script { template class ScriptExecuteAction; -template class ScriptExecuteAction, Ts...> : public Action { +template class ScriptExecuteAction, Ts...> final : public Action { public: ScriptExecuteAction(Script *script) : script_(script) {} @@ -254,7 +254,7 @@ template class ScriptExecuteAction, T Args args_; }; -template class ScriptStopAction : public Action { +template class ScriptStopAction final : public Action { public: ScriptStopAction(C *script) : script_(script) {} @@ -264,7 +264,7 @@ template class ScriptStopAction : public Action C *script_; }; -template class IsRunningCondition : public Condition { +template class IsRunningCondition final : public Condition { public: explicit IsRunningCondition(C *parent) : parent_(parent) {} @@ -281,7 +281,7 @@ template class IsRunningCondition : public Condition class ScriptWaitAction : public Action, public Component { +template class ScriptWaitAction final : public Action, public Component { public: ScriptWaitAction(C *script) : script_(script) {} diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index a5ebf44c38..635eb1e3f8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { constexpr static const char *const TAG = "sdl"; -class Sdl : public display::Display { +class Sdl final : public display::Display { public: display::DisplayType get_display_type() override { return display::DISPLAY_TYPE_COLOR; } void update() override; diff --git a/esphome/components/sdl/touchscreen/sdl_touchscreen.h b/esphome/components/sdl/touchscreen/sdl_touchscreen.h index cf2fd65088..50a584949b 100644 --- a/esphome/components/sdl/touchscreen/sdl_touchscreen.h +++ b/esphome/components/sdl/touchscreen/sdl_touchscreen.h @@ -6,7 +6,7 @@ namespace esphome::sdl { -class SdlTouchscreen : public touchscreen::Touchscreen, public Parented { +class SdlTouchscreen final : public touchscreen::Touchscreen, public Parented { public: void setup() override { this->x_raw_max_ = this->display_->get_width(); diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index e729e29d6c..aa71fcaa47 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -8,7 +8,7 @@ namespace esphome::sdm_meter { -class SDMMeter : public PollingComponent, public modbus::ModbusDevice { +class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: void set_voltage_sensor(uint8_t phase, sensor::Sensor *voltage_sensor) { this->phases_[phase].setup = true; diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8006d0b4ba..46f5025080 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -41,12 +41,15 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@polyfaces", "@jesserockz"] sdm_meter_ns = cg.esphome_ns.namespace("sdm_meter") -SDMMeter = sdm_meter_ns.class_("SDMMeter", cg.PollingComponent, modbus.ModbusDevice) +SDMMeter = sdm_meter_ns.class_( + "SDMMeter", cg.PollingComponent, modbus.ModbusClientDevice +) PHASE_SENSORS = { CONF_VOLTAGE: sensor.sensor_schema( @@ -145,10 +148,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("sdm_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) if CONF_TOTAL_POWER in config: sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) diff --git a/esphome/components/sdp3x/sdp3x.h b/esphome/components/sdp3x/sdp3x.h index c4ef6a4a1e..19c8d0f678 100644 --- a/esphome/components/sdp3x/sdp3x.h +++ b/esphome/components/sdp3x/sdp3x.h @@ -8,7 +8,9 @@ namespace esphome::sdp3x { enum MeasurementMode { MASS_FLOW_AVG, DP_AVG }; -class SDP3XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice, public sensor::Sensor { +class SDP3XComponent final : public PollingComponent, + public sensirion_common::SensirionI2CDevice, + public sensor::Sensor { public: /// Schedule temperature+pressure readings. void update() override; diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index b1f89f18bf..1c222e5e80 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -73,7 +73,6 @@ void SDS011Component::dump_config() { this->update_interval_min_, ONOFF(this->rx_mode_only_)); LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); - this->check_uart_settings(9600); } void SDS011Component::loop() { diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 56d46d118f..4f4571ab69 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -7,7 +7,7 @@ namespace esphome::sds011 { -class SDS011Component : public Component, public uart::UARTDevice { +class SDS011Component final : public Component, public uart::UARTDevice { public: SDS011Component() = default; diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index 76abc70bb7..2d7b6b07e5 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -63,6 +63,24 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config): + # In the default mode setup() writes config commands, so tx is required; + # rx_only mode never writes, so tx is optional. + uart.final_validate_device_schema( + "sds011", + baud_rate=9600, + require_rx=True, + require_tx=not config.get(CONF_RX_ONLY, False), + data_bits=8, + parity="NONE", + stop_bits=1, + )(config) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): # Pop update_interval before register_component so it doesn't generate # a set_update_interval call — sds011 handles this via set_update_interval_min diff --git a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h index bc98bb93b6..fc0cbbdc76 100644 --- a/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h +++ b/esphome/components/seeed_mr24hpc1/button/custom_mode_end_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomSetEndButton : public button::Button, public Parented { +class CustomSetEndButton final : public button::Button, public Parented { public: CustomSetEndButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/button/restart_button.h b/esphome/components/seeed_mr24hpc1/button/restart_button.h index 49a4f46138..c6c530004b 100644 --- a/esphome/components/seeed_mr24hpc1/button/restart_button.h +++ b/esphome/components/seeed_mr24hpc1/button/restart_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class RestartButton : public button::Button, public Parented { +class RestartButton final : public button::Button, public Parented { public: RestartButton() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h index f51e592fc0..842530a379 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_mode_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomModeNumber : public number::Number, public Parented { +class CustomModeNumber final : public number::Number, public Parented { public: CustomModeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h index 281e727a36..0ef2073195 100644 --- a/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/custom_unman_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class CustomUnmanTimeNumber : public number::Number, public Parented { +class CustomUnmanTimeNumber final : public number::Number, public Parented { public: CustomUnmanTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h index c811b2d6b6..11aa45a6dc 100644 --- a/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/existence_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceThresholdNumber : public number::Number, public Parented { +class ExistenceThresholdNumber final : public number::Number, public Parented { public: ExistenceThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h index 748119f198..01f62f67fb 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_threshold_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionThresholdNumber : public number::Number, public Parented { +class MotionThresholdNumber final : public number::Number, public Parented { public: MotionThresholdNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h index dd7947b2a5..44cf89837e 100644 --- a/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motion_trigger_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionTriggerTimeNumber : public number::Number, public Parented { +class MotionTriggerTimeNumber final : public number::Number, public Parented { public: MotionTriggerTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h index 47493e7954..c12f14e79f 100644 --- a/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h +++ b/esphome/components/seeed_mr24hpc1/number/motiontorest_time_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionToRestTimeNumber : public number::Number, public Parented { +class MotionToRestTimeNumber final : public number::Number, public Parented { public: MotionToRestTimeNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h index c1d5435151..954c004e67 100644 --- a/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h +++ b/esphome/components/seeed_mr24hpc1/number/sensitivity_number.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SensitivityNumber : public number::Number, public Parented { +class SensitivityNumber final : public number::Number, public Parented { public: SensitivityNumber() = default; diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b62504ba0e..b231bab33e 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -92,8 +92,8 @@ static const char *const S_BOUNDARY_STR[10] = {"0.5m", "1.0m", "1.5m", "2.0m", " "3.0m", "3.5m", "4.0m", "4.5m", "5.0m"}; // uint: m static const float S_PRESENCE_OF_DETECTION_RANGE_STR[7] = {0.0f, 0.5f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f}; // uint: m -class MR24HPC1Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR24HPC1Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_TEXT_SENSOR SUB_TEXT_SENSOR(heartbeat_state) SUB_TEXT_SENSOR(product_model) diff --git a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h index 878d0525c9..1fce716ed6 100644 --- a/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/existence_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class ExistenceBoundarySelect : public select::Select, public Parented { +class ExistenceBoundarySelect final : public select::Select, public Parented { public: ExistenceBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h index eecdef2019..721bc67f69 100644 --- a/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h +++ b/esphome/components/seeed_mr24hpc1/select/motion_boundary_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class MotionBoundarySelect : public select::Select, public Parented { +class MotionBoundarySelect final : public select::Select, public Parented { public: MotionBoundarySelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h index 377c61b32f..40e365aa7b 100644 --- a/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h +++ b/esphome/components/seeed_mr24hpc1/select/scene_mode_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class SceneModeSelect : public select::Select, public Parented { +class SceneModeSelect final : public select::Select, public Parented { public: SceneModeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h index e68ae5e54f..bba5363565 100644 --- a/esphome/components/seeed_mr24hpc1/select/unman_time_select.h +++ b/esphome/components/seeed_mr24hpc1/select/unman_time_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnmanTimeSelect : public select::Select, public Parented { +class UnmanTimeSelect final : public select::Select, public Parented { public: UnmanTimeSelect() = default; diff --git a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h index 3224640ce7..8b8dbdf5de 100644 --- a/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h +++ b/esphome/components/seeed_mr24hpc1/switch/underlyFuc_switch.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr24hpc1 { -class UnderlyOpenFunctionSwitch : public switch_::Switch, public Parented { +class UnderlyOpenFunctionSwitch final : public switch_::Switch, public Parented { public: UnderlyOpenFunctionSwitch() = default; diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h index 008acc6a57..0ce25790cc 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.h @@ -21,8 +21,8 @@ static const uint16_t HEART_RATE_TYPE_BUFFER = 0x0A15; static const uint16_t DISTANCE_TYPE_BUFFER = 0x0A16; static const uint16_t PRINT_CLOUD_BUFFER = 0x0A04; -class MR60BHA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60BHA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(has_target); #endif diff --git a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h index c1b96d5f08..7a604592c0 100644 --- a/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h +++ b/esphome/components/seeed_mr60fda2/button/get_radar_parameters_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class GetRadarParametersButton : public button::Button, public Parented { +class GetRadarParametersButton final : public button::Button, public Parented { public: GetRadarParametersButton() = default; diff --git a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h index 174ef5425e..cdfb259909 100644 --- a/esphome/components/seeed_mr60fda2/button/reset_radar_button.h +++ b/esphome/components/seeed_mr60fda2/button/reset_radar_button.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class ResetRadarButton : public button::Button, public Parented { +class ResetRadarButton final : public button::Button, public Parented { public: ResetRadarButton() = default; diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h index 0e97447074..f231de5eec 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.h @@ -56,8 +56,8 @@ static const char *const INSTALL_HEIGHT_STR[7] = {"2.4m", "2.5m", "2.6", "2.7m", static const char *const HEIGHT_THRESHOLD_STR[7] = {"0.0m", "0.1m", "0.2m", "0.3m", "0.4m", "0.5m", "0.6m"}; static const char *const SENSITIVITY_STR[3] = {"1", "2", "3"}; -class MR60FDA2Component : public Component, - public uart::UARTDevice { // The class name must be the name defined by text_sensor.py +class MR60FDA2Component final : public Component, + public uart::UARTDevice { // The class name must be the name defined by text_sensor.py #ifdef USE_BINARY_SENSOR SUB_BINARY_SENSOR(people_exist) SUB_BINARY_SENSOR(fall_detected) diff --git a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h index 0e49576658..0c93085337 100644 --- a/esphome/components/seeed_mr60fda2/select/height_threshold_select.h +++ b/esphome/components/seeed_mr60fda2/select/height_threshold_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class HeightThresholdSelect : public select::Select, public Parented { +class HeightThresholdSelect final : public select::Select, public Parented { public: HeightThresholdSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/install_height_select.h b/esphome/components/seeed_mr60fda2/select/install_height_select.h index c1e2a3eeb1..964edfa127 100644 --- a/esphome/components/seeed_mr60fda2/select/install_height_select.h +++ b/esphome/components/seeed_mr60fda2/select/install_height_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class InstallHeightSelect : public select::Select, public Parented { +class InstallHeightSelect final : public select::Select, public Parented { public: InstallHeightSelect() = default; diff --git a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h index f2e0307dc1..1d96257871 100644 --- a/esphome/components/seeed_mr60fda2/select/sensitivity_select.h +++ b/esphome/components/seeed_mr60fda2/select/sensitivity_select.h @@ -5,7 +5,7 @@ namespace esphome::seeed_mr60fda2 { -class SensitivitySelect : public select::Select, public Parented { +class SensitivitySelect final : public select::Select, public Parented { public: SensitivitySelect() = default; diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 159acab124..c367d1d15d 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -15,7 +15,7 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } -class SelecMeter : public PollingComponent, public modbus::ModbusDevice { +class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) SELEC_METER_SENSOR(import_active_energy) diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 1a53eb5c37..ef4929c375 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -32,6 +32,7 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.types import ConfigType AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] @@ -49,7 +50,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kVARh" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( - "SelecMeter", cg.PollingComponent, modbus.ModbusDevice + "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) SENSORS = { @@ -163,10 +164,17 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + return modbus.final_validate_modbus_device("selec_meter", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await modbus.register_modbus_device(var, config) + await modbus.register_modbus_client_device(var, config) for name in SENSORS: if name in config: sens = await sensor.new_sensor(config[name]) diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index ffdabd5f7c..8e5da893ad 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,7 +6,7 @@ namespace esphome::select { -class SelectStateTrigger : public Trigger { +class SelectStateTrigger final : public Trigger { public: explicit SelectStateTrigger(Select *parent) : parent_(parent) { parent->add_on_state_callback( @@ -17,7 +17,7 @@ class SelectStateTrigger : public Trigger { Select *parent_; }; -template class SelectSetAction : public Action { +template class SelectSetAction final : public Action { public: explicit SelectSetAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(std::string, option) @@ -32,7 +32,7 @@ template class SelectSetAction : public Action { Select *select_; }; -template class SelectSetIndexAction : public Action { +template class SelectSetIndexAction final : public Action { public: explicit SelectSetIndexAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(size_t, index) @@ -47,7 +47,7 @@ template class SelectSetIndexAction : public Action { Select *select_; }; -template class SelectOperationAction : public Action { +template class SelectOperationAction final : public Action { public: explicit SelectOperationAction(Select *select) : select_(select) {} TEMPLATABLE_VALUE(bool, cycle) @@ -66,7 +66,7 @@ template class SelectOperationAction : public Action { Select *select_; }; -template class SelectIsCondition : public Condition { +template class SelectIsCondition final : public Condition { public: SelectIsCondition(Select *parent, const char *const *option_list) : parent_(parent), option_list_(option_list) {} @@ -85,7 +85,7 @@ template class SelectIsCondition : public Condition class SelectIsCondition<0, Ts...> : public Condition { +template class SelectIsCondition<0, Ts...> final : public Condition { public: SelectIsCondition(Select *parent, std::function &&f) : parent_(parent), f_(f) {} 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/sen0321/sen0321.h b/esphome/components/sen0321/sen0321.h index 6d5aa20a61..ed7df3fcaf 100644 --- a/esphome/components/sen0321/sen0321.h +++ b/esphome/components/sen0321/sen0321.h @@ -20,7 +20,7 @@ static const uint8_t SET_REGISTER = 0x04; static const uint8_t SENSOR_PASS_READ_REG = 0x07; static const uint8_t SENSOR_AUTO_READ_REG = 0x09; -class Sen0321Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen0321Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen21231/sen21231.h b/esphome/components/sen21231/sen21231.h index 486a9473d2..ad05966011 100644 --- a/esphome/components/sen21231/sen21231.h +++ b/esphome/components/sen21231/sen21231.h @@ -63,7 +63,7 @@ using person_sensor_results_t = struct __attribute__((__packed__)) { uint16_t checksum; // Bytes 38-39. }; -class Sen21231Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class Sen21231Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void update() override; void dump_config() override; diff --git a/esphome/components/sen5x/automation.h b/esphome/components/sen5x/automation.h index e6111f4a8f..21d938c4fe 100644 --- a/esphome/components/sen5x/automation.h +++ b/esphome/components/sen5x/automation.h @@ -6,7 +6,7 @@ namespace esphome::sen5x { -template class StartFanAction : public Action { +template class StartFanAction final : public Action { public: explicit StartFanAction(SEN5XComponent *sen5x) : sen5x_(sen5x) {} diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index ec8f9cc544..6b5a1f8510 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -44,7 +44,7 @@ struct TemperatureCompensation { // Prevents wear of the flash because of too many write operations static const uint32_t SHORTEST_BASELINE_STORE_INTERVAL = 2 * 60 * 60 * 1000; -class SEN5XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN5XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index bc44611882..041bf3b1aa 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -6,7 +6,7 @@ namespace esphome::sen6x { -class SEN6XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SEN6XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(pm_1_0) SUB_SENSOR(pm_2_5) SUB_SENSOR(pm_4_0) 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/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index e8c643f9b9..97e7f4e22c 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -138,6 +138,7 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType: socket.consume_sockets(1, "sendspin_websocket_client")(config) wifi.enable_runtime_power_save_control() + wifi.enable_runtime_roaming_suppression() return config diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h index be3b1eb39d..0b408b1235 100644 --- a/esphome/components/sendspin/automation.h +++ b/esphome/components/sendspin/automation.h @@ -10,7 +10,7 @@ namespace esphome::sendspin_ { #ifdef USE_SENDSPIN_CONTROLLER -template class SendspinSwitchCommandAction : public Action, public Parented { +template class SendspinSwitchCommandAction final : public Action, public Parented { public: void play(const Ts &...x) override { // Clear any EXTERNAL_SOURCE state so the switch command is followed diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 52786d6d7b..651e1562be 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -9,7 +9,7 @@ namespace esphome::sendspin_ { -class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer { +class SendspinMediaPlayer final : public SendspinChild, public media_player::MediaPlayer { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/media_source/automations.h b/esphome/components/sendspin/media_source/automations.h index 08d2b2004b..f5c35f107a 100644 --- a/esphome/components/sendspin/media_source/automations.h +++ b/esphome/components/sendspin/media_source/automations.h @@ -10,13 +10,13 @@ namespace esphome::sendspin_ { template -class EnableStaticDelayAdjustmentAction : public Action, public Parented { +class EnableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } }; template -class DisableStaticDelayAdjustmentAction : public Action, public Parented { +class DisableStaticDelayAdjustmentAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } }; diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 843578783e..1c5cb625bf 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -17,9 +17,9 @@ namespace esphome::sendspin_ { /// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's /// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards /// transport commands to the hub's controller role. -class SendspinMediaSource : public SendspinChild, - public media_source::MediaSource, - public sendspin::PlayerRoleListener { +class SendspinMediaSource final : public SendspinChild, + public media_source::MediaSource, + public sendspin::PlayerRoleListener { public: void setup() override; void dump_config() override; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 57709306cd..b95d95b2bc 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -129,6 +129,7 @@ void SendspinHub::on_request_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->request_high_performance(); + wifi::global_wifi_component->request_roaming_suppression(); } #endif } @@ -137,6 +138,7 @@ void SendspinHub::on_release_high_performance() { #ifdef USE_WIFI if (wifi::global_wifi_component != nullptr) { wifi::global_wifi_component->release_high_performance(); + wifi::global_wifi_component->release_roaming_suppression(); } #endif } diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index cbfe1742c9..5b29fff55f 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -11,7 +11,7 @@ namespace esphome::sendspin_ { -class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { +class SendspinTrackProgressSensor final : public sensor::Sensor, public SendspinPollingChild { public: void dump_config() override; void setup() override; @@ -24,7 +24,7 @@ enum class SendspinNumericMetadataTypes { TRACK, }; -class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { +class SendspinMetadataSensor final : public sensor::Sensor, public SendspinChild { public: void dump_config() override; void setup() override; diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index 203b01d024..d38f360d94 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -18,7 +18,7 @@ enum class SendspinTextMetadataTypes { ALBUM_ARTIST, }; -class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { +class SendspinTextSensor final : public SendspinChild, public text_sensor::TextSensor { public: void dump_config() override; void setup() override; diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 8ed9fbb53b..0e8e4cef97 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -146,7 +146,6 @@ bool SenseAirComponent::senseair_write_command_(const uint8_t *command, uint8_t void SenseAirComponent::dump_config() { ESP_LOGCONFIG(TAG, "SenseAir:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::senseair diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 333c003f48..48154a53d9 100644 --- a/esphome/components/senseair/senseair.h +++ b/esphome/components/senseair/senseair.h @@ -18,7 +18,7 @@ enum SenseAirStatus : uint8_t { RESERVED = 1 << 7 }; -class SenseAirComponent : public PollingComponent, public uart::UARTDevice { +class SenseAirComponent final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } @@ -37,7 +37,7 @@ class SenseAirComponent : public PollingComponent, public uart::UARTDevice { sensor::Sensor *co2_sensor_{nullptr}; }; -template class SenseAirBackgroundCalibrationAction : public Action { +template class SenseAirBackgroundCalibrationAction final : public Action { public: SenseAirBackgroundCalibrationAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -47,7 +47,7 @@ template class SenseAirBackgroundCalibrationAction : public Acti SenseAirComponent *senseair_; }; -template class SenseAirBackgroundCalibrationResultAction : public Action { +template class SenseAirBackgroundCalibrationResultAction final : public Action { public: SenseAirBackgroundCalibrationResultAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -57,7 +57,7 @@ template class SenseAirBackgroundCalibrationResultAction : publi SenseAirComponent *senseair_; }; -template class SenseAirABCEnableAction : public Action { +template class SenseAirABCEnableAction final : public Action { public: SenseAirABCEnableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -67,7 +67,7 @@ template class SenseAirABCEnableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCDisableAction : public Action { +template class SenseAirABCDisableAction final : public Action { public: SenseAirABCDisableAction(SenseAirComponent *senseair) : senseair_(senseair) {} @@ -77,7 +77,7 @@ template class SenseAirABCDisableAction : public Action { SenseAirComponent *senseair_; }; -template class SenseAirABCGetPeriodAction : public Action { +template class SenseAirABCGetPeriodAction final : public Action { public: SenseAirABCGetPeriodAction(SenseAirComponent *senseair) : senseair_(senseair) {} diff --git a/esphome/components/senseair/sensor.py b/esphome/components/senseair/sensor.py index c5bef76741..277648137a 100644 --- a/esphome/components/senseair/sensor.py +++ b/esphome/components/senseair/sensor.py @@ -51,6 +51,16 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "senseair", + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 5a2ebf03c0..6ad76046a1 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -87,6 +87,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -166,6 +167,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, @@ -319,13 +321,25 @@ _SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent), cv.GenerateID(): cv.declare_id(Sensor), - cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement, - cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals, - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, - cv.Optional(CONF_STATE_CLASS): validate_state_class, - cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category, - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, - cv.Optional(CONF_EXPIRE_AFTER): cv.All( + cv.Optional( + CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED + ): validate_unit_of_measurement, + cv.Optional( + CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED + ): validate_accuracy_decimals, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, + cv.Optional( + CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_state_class, + cv.Optional( + CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED + ): sensor_entity_category, + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.boolean, + cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All( cv.requires_component("mqtt"), cv.Any(None, cv.positive_time_period_milliseconds), ), diff --git a/esphome/components/sensor/automation.h b/esphome/components/sensor/automation.h index 37578f5320..35a4a29e0d 100644 --- a/esphome/components/sensor/automation.h +++ b/esphome/components/sensor/automation.h @@ -6,21 +6,21 @@ namespace esphome::sensor { -class SensorStateTrigger : public Trigger { +class SensorStateTrigger final : public Trigger { public: explicit SensorStateTrigger(Sensor *parent) { parent->add_on_state_callback([this](float value) { this->trigger(value); }); } }; -class SensorRawStateTrigger : public Trigger { +class SensorRawStateTrigger final : public Trigger { public: explicit SensorRawStateTrigger(Sensor *parent) { parent->add_on_raw_state_callback([this](float value) { this->trigger(value); }); } }; -template class SensorPublishAction : public Action { +template class SensorPublishAction final : public Action { public: SensorPublishAction(Sensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(float, state) @@ -31,7 +31,7 @@ template class SensorPublishAction : public Action { Sensor *sensor_; }; -class ValueRangeTrigger : public Trigger, public Component { +class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Sensor *parent) : parent_(parent) {} @@ -83,7 +83,7 @@ class ValueRangeTrigger : public Trigger, public Component { TemplatableFn max_{[](float) -> float { return NAN; }}; }; -template class SensorInRangeCondition : public Condition { +template class SensorInRangeCondition final : public Condition { public: SensorInRangeCondition(Sensor *parent) : parent_(parent) {} diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index c435787a61..e35fab3d42 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -41,7 +41,7 @@ enum SerialProxyLineStateFlag : uint32_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; -class SerialProxy : public uart::UARTDevice, public Component { +class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index d2028ce9bd..8d5344cf44 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -86,7 +86,7 @@ void Servo::write(float value) { void Servo::internal_write(float value) { value = clamp(value, -1.0f, 1.0f); float level; - if (value < 0.0) { + if (value < 0.0f) { level = std::lerp(this->idle_level_, this->min_level_, -value); } else { level = std::lerp(this->idle_level_, this->max_level_, value); diff --git a/esphome/components/servo/servo.h b/esphome/components/servo/servo.h index 31e9357947..156dab6dc1 100644 --- a/esphome/components/servo/servo.h +++ b/esphome/components/servo/servo.h @@ -10,7 +10,7 @@ namespace esphome::servo { extern uint32_t global_servo_id; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class Servo : public Component { +class Servo final : public Component { public: void set_output(output::FloatOutput *output) { output_ = output; } void loop() override; @@ -51,7 +51,7 @@ class Servo : public Component { }; }; -template class ServoWriteAction : public Action { +template class ServoWriteAction final : public Action { public: ServoWriteAction(Servo *servo) : servo_(servo) {} TEMPLATABLE_VALUE(float, value) @@ -62,7 +62,7 @@ template class ServoWriteAction : public Action { Servo *servo_; }; -template class ServoDetachAction : public Action { +template class ServoDetachAction final : public Action { public: ServoDetachAction(Servo *servo) : servo_(servo) {} diff --git a/esphome/components/sfa30/sfa30.h b/esphome/components/sfa30/sfa30.h index d2f2520a57..13985b1a29 100644 --- a/esphome/components/sfa30/sfa30.h +++ b/esphome/components/sfa30/sfa30.h @@ -6,7 +6,7 @@ namespace esphome::sfa30 { -class SFA30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SFA30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { enum ErrorCode { DEVICE_MARKING_READ_FAILED, MEASUREMENT_INIT_FAILED, UNKNOWN }; public: diff --git a/esphome/components/sgp30/sgp30.h b/esphome/components/sgp30/sgp30.h index cb4aa1c1bb..fac3c01b58 100644 --- a/esphome/components/sgp30/sgp30.h +++ b/esphome/components/sgp30/sgp30.h @@ -16,7 +16,7 @@ struct SGP30Baselines { } PACKED; /// This class implements support for the Sensirion SGP30 i2c GAS (VOC and CO2eq) sensors. -class SGP30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SGP30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_eco2_sensor(sensor::Sensor *eco2) { eco2_sensor_ = eco2; } void set_tvoc_sensor(sensor::Sensor *tvoc) { tvoc_sensor_ = tvoc; } diff --git a/esphome/components/sgp4x/sgp4x.h b/esphome/components/sgp4x/sgp4x.h index 23bf6319a9..a40188e629 100644 --- a/esphome/components/sgp4x/sgp4x.h +++ b/esphome/components/sgp4x/sgp4x.h @@ -54,7 +54,9 @@ const float MAXIMUM_STORAGE_DIFF = 50.0f; class SGP4xComponent; /// This class implements support for the Sensirion sgp4x i2c GAS (VOC) sensors. -class SGP4xComponent : public PollingComponent, public sensor::Sensor, public sensirion_common::SensirionI2CDevice { +class SGP4xComponent final : public PollingComponent, + public sensor::Sensor, + public sensirion_common::SensirionI2CDevice { enum ErrorCode { COMMUNICATION_FAILED, MEASUREMENT_INIT_FAILED, diff --git a/esphome/components/sha256/sha256.cpp b/esphome/components/sha256/sha256.cpp index 079665c959..136d0f1d58 100644 --- a/esphome/components/sha256/sha256.cpp +++ b/esphome/components/sha256/sha256.cpp @@ -1,7 +1,7 @@ #include "sha256.h" // Only compile SHA256 implementation on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include "esphome/core/helpers.h" #include @@ -76,7 +76,7 @@ void SHA256::add(const uint8_t *data, size_t len) { mbedtls_sha256_update(&this- void SHA256::calculate() { mbedtls_sha256_finish(&this->ctx_, this->digest_); } -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) SHA256::~SHA256() = default; diff --git a/esphome/components/sha256/sha256.h b/esphome/components/sha256/sha256.h index d10d418c7a..26afe9e33e 100644 --- a/esphome/components/sha256/sha256.h +++ b/esphome/components/sha256/sha256.h @@ -3,7 +3,7 @@ #include "esphome/core/defines.h" // Only define SHA256 on platforms that support it -#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_HOST) #include #include @@ -25,7 +25,7 @@ #elif defined(USE_LIBRETINY) #define USE_SHA256_MBEDTLS #include "mbedtls/sha256.h" -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) #include #elif defined(USE_HOST) #include @@ -70,7 +70,7 @@ class SHA256 final : public esphome::HashBase { // The mbedtls context for ESP32-S3 hardware SHA requires proper alignment and stack frame constraints. // See class documentation above for critical requirements. mbedtls_sha256_context ctx_{}; -#elif defined(USE_ESP8266) || defined(USE_RP2040) +#elif defined(USE_ESP8266) || defined(USE_RP2) br_sha256_context ctx_{}; bool calculated_{false}; #elif defined(USE_HOST) diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index ddf7fa161b..f2ab5a4bc1 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -186,6 +186,10 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "shelly_dimmer", baud_rate=115200, require_rx=True, require_tx=True +) + async def to_code(config): fw_hex = get_firmware(config[CONF_FIRMWARE]) diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index b0f43f0ffc..b69e417591 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -207,7 +207,7 @@ bool ShellyDimmer::upgrade_firmware_() { uint16_t ShellyDimmer::convert_brightness_(float brightness) { // Special case for zero as only zero means turn off completely. - if (brightness == 0.0) { + if (brightness == 0.0f) { return 0; } diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.h b/esphome/components/shelly_dimmer/shelly_dimmer.h index c6d0e20afe..e3ddd7f268 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.h +++ b/esphome/components/shelly_dimmer/shelly_dimmer.h @@ -12,7 +12,7 @@ namespace esphome::shelly_dimmer { -class ShellyDimmer : public PollingComponent, public light::LightOutput, public uart::UARTDevice { +class ShellyDimmer final : public PollingComponent, public light::LightOutput, public uart::UARTDevice { private: static constexpr uint16_t SHELLY_DIMMER_BUFFER_SIZE = 256; diff --git a/esphome/components/sht3xd/sht3xd.h b/esphome/components/sht3xd/sht3xd.h index 6df5587507..93663118e5 100644 --- a/esphome/components/sht3xd/sht3xd.h +++ b/esphome/components/sht3xd/sht3xd.h @@ -7,7 +7,7 @@ namespace esphome::sht3xd { /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHT3XDComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT3XDComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index d1fa9033df..0d5723f72a 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -14,7 +14,7 @@ enum SHT4XHEATERPOWER { SHT4X_HEATERPOWER_HIGH, SHT4X_HEATERPOWER_MED, SHT4X_HEA enum SHT4XHEATERTIME : uint16_t { SHT4X_HEATERTIME_LONG = 1100, SHT4X_HEATERTIME_SHORT = 110 }; -class SHT4XComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHT4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/shtcx/shtcx.h b/esphome/components/shtcx/shtcx.h index a86b204e2b..ea50a084ef 100644 --- a/esphome/components/shtcx/shtcx.h +++ b/esphome/components/shtcx/shtcx.h @@ -13,7 +13,7 @@ enum SHTCXType : uint8_t { }; /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. -class SHTCXComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SHTCXComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } diff --git a/esphome/components/shutdown/button/shutdown_button.h b/esphome/components/shutdown/button/shutdown_button.h index d4247ec0f9..4fc534030e 100644 --- a/esphome/components/shutdown/button/shutdown_button.h +++ b/esphome/components/shutdown/button/shutdown_button.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownButton : public button::Button, public Component { +class ShutdownButton final : public button::Button, public Component { public: void dump_config() override; diff --git a/esphome/components/shutdown/switch/shutdown_switch.h b/esphome/components/shutdown/switch/shutdown_switch.h index 933345915f..bb7fea7e03 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.h +++ b/esphome/components/shutdown/switch/shutdown_switch.h @@ -5,7 +5,7 @@ namespace esphome::shutdown { -class ShutdownSwitch : public switch_::Switch, public Component { +class ShutdownSwitch final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.h b/esphome/components/sigma_delta_output/sigma_delta_output.h index a5df3c6c7c..71aedf9b07 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.h +++ b/esphome/components/sigma_delta_output/sigma_delta_output.h @@ -6,7 +6,7 @@ namespace esphome::sigma_delta_output { -class SigmaDeltaOutput : public PollingComponent, public output::FloatOutput { +class SigmaDeltaOutput final : public PollingComponent, public output::FloatOutput { public: Trigger<> *get_turn_on_trigger() { if (!this->turn_on_trigger_) diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 0b3259ede0..276131cfed 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -46,7 +46,7 @@ enum State { STATE_RECEIVED_USSD }; -class Sim800LComponent : public uart::UARTDevice, public PollingComponent { +class Sim800LComponent final : public uart::UARTDevice, public PollingComponent { public: /// Retrieve the latest sensor values. This operation takes approximately 16ms. void update() override; @@ -120,7 +120,7 @@ class Sim800LComponent : public uart::UARTDevice, public PollingComponent { CallbackManager ussd_received_callback_; }; -template class Sim800LSendSmsAction : public Action { +template class Sim800LSendSmsAction final : public Action { public: Sim800LSendSmsAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -136,7 +136,7 @@ template class Sim800LSendSmsAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LSendUssdAction : public Action { +template class Sim800LSendUssdAction final : public Action { public: Sim800LSendUssdAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, ussd) @@ -150,7 +150,7 @@ template class Sim800LSendUssdAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDialAction : public Action { +template class Sim800LDialAction final : public Action { public: Sim800LDialAction(Sim800LComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(std::string, recipient) @@ -163,7 +163,7 @@ template class Sim800LDialAction : public Action { protected: Sim800LComponent *parent_; }; -template class Sim800LConnectAction : public Action { +template class Sim800LConnectAction final : public Action { public: Sim800LConnectAction(Sim800LComponent *parent) : parent_(parent) {} @@ -173,7 +173,7 @@ template class Sim800LConnectAction : public Action { Sim800LComponent *parent_; }; -template class Sim800LDisconnectAction : public Action { +template class Sim800LDisconnectAction final : public Action { public: Sim800LDisconnectAction(Sim800LComponent *parent) : parent_(parent) {} diff --git a/esphome/components/slow_pwm/slow_pwm_output.h b/esphome/components/slow_pwm/slow_pwm_output.h index d866435af1..aa517a3bc5 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.h +++ b/esphome/components/slow_pwm/slow_pwm_output.h @@ -6,7 +6,7 @@ namespace esphome::slow_pwm { -class SlowPWMOutput : public output::FloatOutput, public Component { +class SlowPWMOutput final : public output::FloatOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; }; void set_period(unsigned int period) { period_ = period; }; diff --git a/esphome/components/sm10bit_base/sm10bit_base.h b/esphome/components/sm10bit_base/sm10bit_base.h index b419b86dbf..a22c4da36e 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.h +++ b/esphome/components/sm10bit_base/sm10bit_base.h @@ -27,7 +27,7 @@ class Sm10BitBase : public Component { void dump_config() override; void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(Sm10BitBase *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm16716/sm16716.h b/esphome/components/sm16716/sm16716.h index 09deb2e8bf..8a76fd86f0 100644 --- a/esphome/components/sm16716/sm16716.h +++ b/esphome/components/sm16716/sm16716.h @@ -7,7 +7,7 @@ namespace esphome::sm16716 { -class SM16716 : public Component { +class SM16716 final : public Component { public: class Channel; @@ -25,7 +25,7 @@ class SM16716 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM16716 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2135/sm2135.h b/esphome/components/sm2135/sm2135.h index 040ec14b7f..6bf77cf554 100644 --- a/esphome/components/sm2135/sm2135.h +++ b/esphome/components/sm2135/sm2135.h @@ -21,7 +21,7 @@ enum SM2135Current : uint8_t { SM2135_CURRENT_60MA = 0x0A, }; -class SM2135 : public Component { +class SM2135 final : public Component { public: class Channel; @@ -49,7 +49,7 @@ class SM2135 : public Component { /// Send new values if they were updated. void loop() override; - class Channel : public output::FloatOutput { + class Channel final : public output::FloatOutput { public: void set_parent(SM2135 *parent) { parent_ = parent; } void set_channel(uint8_t channel) { channel_ = channel; } diff --git a/esphome/components/sm2235/sm2235.h b/esphome/components/sm2235/sm2235.h index cdb754e298..dbb51945f6 100644 --- a/esphome/components/sm2235/sm2235.h +++ b/esphome/components/sm2235/sm2235.h @@ -6,7 +6,7 @@ namespace esphome::sm2235 { -class SM2235 : public sm10bit_base::Sm10BitBase { +class SM2235 final : public sm10bit_base::Sm10BitBase { public: SM2235() = default; diff --git a/esphome/components/sm2335/sm2335.h b/esphome/components/sm2335/sm2335.h index 44e0e5b03f..7c4f0269aa 100644 --- a/esphome/components/sm2335/sm2335.h +++ b/esphome/components/sm2335/sm2335.h @@ -6,7 +6,7 @@ namespace esphome::sm2335 { -class SM2335 : public sm10bit_base::Sm10BitBase { +class SM2335 final : public sm10bit_base::Sm10BitBase { public: SM2335() = default; diff --git a/esphome/components/sm300d2/sensor.py b/esphome/components/sm300d2/sensor.py index 60c9ccc40d..29e0cfe9b1 100644 --- a/esphome/components/sm300d2/sensor.py +++ b/esphome/components/sm300d2/sensor.py @@ -88,6 +88,10 @@ CONFIG_SCHEMA = cv.All( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "sm300d2", baud_rate=9600, require_rx=True, data_bits=8, parity="NONE", stop_bits=1 +) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 391cc0ac11..882959a454 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -100,7 +100,6 @@ void SM300D2Sensor::dump_config() { LOG_SENSOR(" ", "PM10", this->pm_10_0_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::sm300d2 diff --git a/esphome/components/sm300d2/sm300d2.h b/esphome/components/sm300d2/sm300d2.h index 629e758e30..87c60e92a1 100644 --- a/esphome/components/sm300d2/sm300d2.h +++ b/esphome/components/sm300d2/sm300d2.h @@ -6,7 +6,7 @@ namespace esphome::sm300d2 { -class SM300D2Sensor : public PollingComponent, public uart::UARTDevice { +class SM300D2Sensor final : public PollingComponent, public uart::UARTDevice { public: void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } void set_formaldehyde_sensor(sensor::Sensor *formaldehyde_sensor) { formaldehyde_sensor_ = formaldehyde_sensor; } diff --git a/esphome/components/sml/sensor/sml_sensor.h b/esphome/components/sml/sensor/sml_sensor.h index d2f8a7743f..a73af28f66 100644 --- a/esphome/components/sml/sensor/sml_sensor.h +++ b/esphome/components/sml/sensor/sml_sensor.h @@ -4,7 +4,7 @@ namespace esphome::sml { -class SmlSensor : public SmlListener, public sensor::Sensor, public Component { +class SmlSensor final : public SmlListener, public sensor::Sensor, public Component { public: SmlSensor(std::string server_id, std::string obis_code); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/sml/sml.h b/esphome/components/sml/sml.h index 60a80e3ad8..b59526648d 100644 --- a/esphome/components/sml/sml.h +++ b/esphome/components/sml/sml.h @@ -17,7 +17,7 @@ class SmlListener { virtual void publish_val(const ObisInfo &obis_info){}; }; -class Sml : public Component, public uart::UARTDevice { +class Sml final : public Component, public uart::UARTDevice { public: void register_sml_listener(SmlListener *listener); void loop() override; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.h b/esphome/components/sml/text_sensor/sml_text_sensor.h index 6194f22349..d445d514e9 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.h +++ b/esphome/components/sml/text_sensor/sml_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sml { -class SmlTextSensor : public SmlListener, public text_sensor::TextSensor, public Component { +class SmlTextSensor final : public SmlListener, public text_sensor::TextSensor, public Component { public: SmlTextSensor(std::string server_id, std::string obis_code, SmlType format); void publish_val(const ObisInfo &obis_info) override; diff --git a/esphome/components/smt100/smt100.h b/esphome/components/smt100/smt100.h index b68151eeb4..55977a5caf 100644 --- a/esphome/components/smt100/smt100.h +++ b/esphome/components/smt100/smt100.h @@ -6,7 +6,7 @@ namespace esphome::smt100 { -class SMT100Component : public PollingComponent, public uart::UARTDevice { +class SMT100Component final : public PollingComponent, public uart::UARTDevice { static const uint16_t MAX_LINE_LENGTH = 31; public: diff --git a/esphome/components/sn74hc165/sn74hc165.h b/esphome/components/sn74hc165/sn74hc165.h index 596f2eb4f5..9e80aa67bf 100644 --- a/esphome/components/sn74hc165/sn74hc165.h +++ b/esphome/components/sn74hc165/sn74hc165.h @@ -8,7 +8,7 @@ namespace esphome::sn74hc165 { -class SN74HC165Component : public Component { +class SN74HC165Component final : public Component { public: SN74HC165Component() = default; @@ -40,7 +40,7 @@ class SN74HC165Component : public Component { }; /// Helper class to expose a SC74HC165 pin as an internal input GPIO pin. -class SN74HC165GPIOPin : public GPIOPin, public Parented { +class SN74HC165GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} diff --git a/esphome/components/sn74hc595/sn74hc595.h b/esphome/components/sn74hc595/sn74hc595.h index 23977e3d04..0b291b9ee5 100644 --- a/esphome/components/sn74hc595/sn74hc595.h +++ b/esphome/components/sn74hc595/sn74hc595.h @@ -47,7 +47,7 @@ class SN74HC595Component : public Component { }; /// Helper class to expose a SC74HC595 pin as an internal output GPIO pin. -class SN74HC595GPIOPin : public GPIOPin, public Parented { +class SN74HC595GPIOPin final : public GPIOPin, public Parented { public: void setup() override {} void pin_mode(gpio::Flags flags) override {} @@ -66,7 +66,7 @@ class SN74HC595GPIOPin : public GPIOPin, public Parented { bool inverted_; }; -class SN74HC595GPIOComponent : public SN74HC595Component { +class SN74HC595GPIOComponent final : public SN74HC595Component { public: void setup() override; void set_data_pin(GPIOPin *pin) { data_pin_ = pin; } @@ -80,9 +80,9 @@ class SN74HC595GPIOComponent : public SN74HC595Component { }; #ifdef USE_SPI -class SN74HC595SPIComponent : public SN74HC595Component, - public spi::SPIDevice { +class SN74HC595SPIComponent final : public SN74HC595Component, + public spi::SPIDevice { public: void setup() override; diff --git a/esphome/components/sntp/sntp_component.h b/esphome/components/sntp/sntp_component.h index ef737c1978..686fb30d25 100644 --- a/esphome/components/sntp/sntp_component.h +++ b/esphome/components/sntp/sntp_component.h @@ -15,7 +15,7 @@ namespace esphome::sntp { /// The C library (newlib) available on ESPs only supports TZ strings that specify an offset and DST info; /// you cannot specify zone names or paths to zoneinfo files. /// \see https://www.gnu.org/software/libc/manual/html_node/TZ-Variable.html -class SNTPComponent : public time::RealTimeClock { +class SNTPComponent final : public time::RealTimeClock { public: SNTPComponent(const std::array &servers) : servers_(servers) {} diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 69a2436d3d..7d592f8ef8 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -98,7 +98,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index abbbb0f056..cd002d9eb0 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -144,11 +144,12 @@ CONFIG_SCHEMA = cv.Schema( CONF_IMPLEMENTATION, esp8266=IMPLEMENTATION_LWIP_TCP, esp32=IMPLEMENTATION_BSD_SOCKETS, - rp2040=IMPLEMENTATION_LWIP_TCP, + rp2=IMPLEMENTATION_LWIP_TCP, bk72xx=IMPLEMENTATION_LWIP_SOCKETS, ln882x=IMPLEMENTATION_LWIP_SOCKETS, rtl87xx=IMPLEMENTATION_LWIP_SOCKETS, host=IMPLEMENTATION_BSD_SOCKETS, + nrf52=IMPLEMENTATION_BSD_SOCKETS, ): cv.one_of( IMPLEMENTATION_LWIP_TCP, IMPLEMENTATION_LWIP_SOCKETS, @@ -168,6 +169,11 @@ async def to_code(config): cg.add_define("USE_SOCKET_IMPL_LWIP_SOCKETS") elif impl == IMPLEMENTATION_BSD_SOCKETS: cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") + if CORE.using_zephyr: + from esphome.components.zephyr import zephyr_add_prj_conf + + zephyr_add_prj_conf("NET_SOCKETS", True) + zephyr_add_prj_conf("POSIX_API", True) # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() # and FreeRTOS task notifications — enable fast select to bypass lwip_select(). # Only when not using lwip_tcp, which does not provide select() support. diff --git a/esphome/components/socket/bsd_sockets_impl.cpp b/esphome/components/socket/bsd_sockets_impl.cpp index ee22e4b97b..0d4284f145 100644 --- a/esphome/components/socket/bsd_sockets_impl.cpp +++ b/esphome/components/socket/bsd_sockets_impl.cpp @@ -22,11 +22,13 @@ BSDSocketImpl::BSDSocketImpl(int fd, bool monitor_loop) { if (flags >= 0) ::fcntl(this->fd_, F_SETFD, flags | FD_CLOEXEC); #endif + // Guard structure matches socket_ready_fd(): non-HOST platforms (nRF52/OpenThread) + // do not register fds with the esphome select loop, so monitor_loop is a no-op there. if (!monitor_loop) return; #ifdef USE_LWIP_FAST_SELECT this->cached_sock_ = hook_fd_for_fast_select(this->fd_); -#else +#elif defined(USE_HOST) this->loop_monitored_ = wake_register_fd(this->fd_); #endif } @@ -45,7 +47,7 @@ int BSDSocketImpl::close() { // touch an unrelated socket's pcb. No per-socket callback unhook is needed — // all LwIP sockets share the same static event_callback. this->cached_sock_ = nullptr; -#else +#elif defined(USE_HOST) if (this->loop_monitored_) { wake_unregister_fd(this->fd_); } diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 57c1a430a2..1b5ea9ebcd 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -76,7 +76,7 @@ class BSDSocketImpl { #endif } ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) { -#if defined(USE_ESP32) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) return ::recvfrom(this->fd_, buf, len, 0, addr, addr_len); #else return ::lwip_recvfrom(this->fd_, buf, len, 0, addr, addr_len); @@ -85,6 +85,19 @@ class BSDSocketImpl { ssize_t readv(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_readv(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide readv(); emulate with a read() loop. Stream sockets only: + // on a datagram socket each read() would consume a separate datagram, not scatter one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::read(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; + } + return total; #else return ::readv(this->fd_, iov, iovcnt); #endif @@ -100,6 +113,19 @@ class BSDSocketImpl { ssize_t writev(const struct iovec *iov, int iovcnt) { #if defined(USE_ESP32) return ::lwip_writev(this->fd_, iov, iovcnt); +#elif defined(USE_ZEPHYR) + // Zephyr does not provide writev(); emulate with a write() loop. Stream sockets only: + // on a datagram socket each write() would emit a separate datagram, not gather one. + ssize_t total = 0; + for (int i = 0; i < iovcnt; i++) { + ssize_t n = ::write(this->fd_, iov[i].iov_base, iov[i].iov_len); + if (n < 0) + return total > 0 ? total : n; + total += n; + if (static_cast(n) < iov[i].iov_len) + break; // partial write: stop so caller resumes from the correct stream offset + } + return total; #else return ::writev(this->fd_, iov, iovcnt); #endif diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 0eece6480f..528d201799 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -104,7 +104,7 @@ struct iovec { size_t iov_len; }; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // arduino-esp8266 declares a global vars called INADDR_NONE/ANY which are invalid with the define #ifdef INADDR_ANY #undef INADDR_ANY @@ -158,7 +158,9 @@ using socklen_t = uint32_t; #include #include #include +#ifndef USE_ZEPHYR #include +#endif #include #ifdef USE_HOST @@ -167,6 +169,10 @@ using socklen_t = uint32_t; #include #include #endif // USE_HOST +#ifdef USE_ZEPHYR +#include +#include +#endif // USE_ZEPHYR #ifdef USE_ARDUINO // arduino-esp32 declares a global var called INADDR_NONE which is replaced diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index c6692b0165..4fcec553fa 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -17,7 +17,7 @@ extern "C" void esphome_wake_ota_component_any_context(); #ifdef USE_ESP8266 #include // For esp_schedule() -#elif defined(USE_RP2040) +#elif defined(USE_RP2) #include // For __sev(), __wfe() #include // For add_alarm_in_ms(), cancel_alarm() #endif diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f14ac1e2d5..212da80312 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -12,9 +12,19 @@ namespace esphome::socket { #ifdef USE_HOST -// Shared ready() implementation for fd-based socket implementations (BSD and LWIP sockets). -// Checks if the host wake select() loop has marked this fd as ready. +// Host: ready when the wake select() loop has flagged this fd (or it isn't monitored). bool socket_ready_fd(int fd, bool loop_monitored) { return !loop_monitored || wake_fd_ready(fd); } +#elif defined(USE_ZEPHYR) +// Zephyr (nRF52): fd monitoring isn't wired into the esphome select loop +// (wake_register_fd is USE_HOST-only), so loop_monitored is always false. Always +// return true — the caller handles EAGAIN/EWOULDBLOCK on read. +// +// Cost (known trade-off, not an oversight): loop-monitored sockets (API, web_server) +// are read every loop() iteration and bail on EAGAIN; there is no event-driven wake, +// so the main loop busy-polls at loop frequency and cannot idle between packets. +// TODO: wire Zephyr fds into an event-driven wake source (e.g. zsock_poll/k_poll) so +// the loop can sleep between packets on battery/OpenThread targets. +bool socket_ready_fd(int /*fd*/, bool /*loop_monitored*/) { return true; } #endif // Platform-specific inet_ntop wrappers @@ -40,6 +50,19 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t return lwip_inet_ntop(AF_INET6, addr, buf, size); } #endif +#elif defined(USE_ZEPHYR) +// Zephyr BSD sockets — use Zephyr native address formatting via POSIX-subset wrappers. +// is already included transitively through . +static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET, addr, buf, size); +} +// IPv6 is always enabled on nRF52 (config validation enforces enable_ipv6=True), +// but the guard is retained for consistency with other platform blocks. +#if USE_NETWORK_IPV6 +static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) { + return zsock_inet_ntop(AF_INET6, addr, buf, size); +} +#endif #else // BSD sockets (host, ESP32-IDF) static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) { @@ -68,6 +91,15 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { return strlen(buf.data()); } +#elif defined(USE_ZEPHYR) + // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX + // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so + // detect the ::ffff:0:0/96 prefix directly on the address words. + if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && + addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && + esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { + return strlen(buf.data()); + } #elif !defined(USE_SOCKET_IMPL_LWIP_TCP) // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && @@ -117,11 +149,19 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ server->sin6_port = htons(port); #ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { + errno = EINVAL; + return 0; + } +#else // Use standard inet_pton for BSD sockets if (inet_pton(AF_INET6, ip_address, &server->sin6_addr) != 1) { errno = EINVAL; return 0; } +#endif #else // Use LWIP-specific functions ip6_addr_t ip6; @@ -138,7 +178,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ auto *server = reinterpret_cast(addr); memset(server, 0, sizeof(sockaddr_in)); server->sin_family = AF_INET; +#if defined(USE_ZEPHYR) + // Zephyr BSD sockets: use native address conversion + if (zsock_inet_pton(AF_INET, ip_address, &server->sin_addr) != 1) { + errno = EINVAL; + return 0; + } +#else server->sin_addr.s_addr = inet_addr(ip_address); +#endif server->sin_port = htons(port); return sizeof(sockaddr_in); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 204113e4b2..eb8870786d 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -60,11 +60,11 @@ inline struct lwip_sock *hook_fd_for_fast_select(int fd) { } return sock; } -#elif defined(USE_HOST) +#elif defined(USE_HOST) || defined(USE_ZEPHYR) /// Shared ready() helper for fd-based socket implementations. /// Checks if the Application's select() loop has marked this fd as ready. bool socket_ready_fd(int fd, bool loop_monitored); -#endif +#endif // USE_LWIP_FAST_SELECT // Inline ready() — defined here because it depends on socket_ready/socket_ready_fd // declared above, while the impl headers are included before those declarations. diff --git a/esphome/components/sonoff_d1/sonoff_d1.h b/esphome/components/sonoff_d1/sonoff_d1.h index a92877e6c8..b7fcb1efa7 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.h +++ b/esphome/components/sonoff_d1/sonoff_d1.h @@ -41,7 +41,7 @@ namespace esphome::sonoff_d1 { -class SonoffD1Output : public light::LightOutput, public uart::UARTDevice, public Component { +class SonoffD1Output final : public light::LightOutput, public uart::UARTDevice, public Component { public: // LightOutput methods light::LightTraits get_traits() override; diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index a93e396367..99ab7932d6 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -121,7 +121,7 @@ void SoundLevelComponent::loop() { if (this->sample_count_ == samples_in_window) { // Processed enough samples for the measurement window, compute and publish the sensor values if (this->peak_sensor_ != nullptr) { - const float peak_db = 10.0f * log10(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); + const float peak_db = 10.0f * log10f(static_cast(this->squared_peak_) / MAX_SAMPLE_SQUARED_DENOMINATOR); this->peak_sensor_->publish_state(peak_db); this->squared_peak_ = 0; // reset accumulator diff --git a/esphome/components/sound_level/sound_level.h b/esphome/components/sound_level/sound_level.h index aabea62ca4..94c18421ba 100644 --- a/esphome/components/sound_level/sound_level.h +++ b/esphome/components/sound_level/sound_level.h @@ -12,7 +12,7 @@ namespace esphome::sound_level { -class SoundLevelComponent : public Component { +class SoundLevelComponent final : public Component { public: void dump_config() override; void setup() override; @@ -59,12 +59,12 @@ class SoundLevelComponent : public Component { uint32_t measurement_duration_ms_; }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start(); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index b0490628cb..d3de5168e4 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -224,7 +224,7 @@ bool SPA06Component::soft_reset_() { } // Temperature conversion formula. See datasheet pg. 14 -float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5 + this->c1_ * t_raw_sc; } +float SPA06Component::convert_temperature_(const float &t_raw_sc) { return this->c0_ * 0.5f + this->c1_ * t_raw_sc; } // Pressure conversion formula. See datasheet pg. 14 float SPA06Component::convert_pressure_(const float &p_raw_sc, const float &t_raw_sc) { float p2_raw_sc = p_raw_sc * p_raw_sc; diff --git a/esphome/components/spa06_i2c/spa06_i2c.h b/esphome/components/spa06_i2c/spa06_i2c.h index 6b4bce3a4e..05e60cbb5d 100644 --- a/esphome/components/spa06_i2c/spa06_i2c.h +++ b/esphome/components/spa06_i2c/spa06_i2c.h @@ -4,7 +4,7 @@ namespace esphome::spa06_i2c { -class SPA06I2CComponent : public spa06_base::SPA06Component, public i2c::I2CDevice { +class SPA06I2CComponent final : public spa06_base::SPA06Component, public i2c::I2CDevice { public: bool spa_read_byte(uint8_t a_register, uint8_t *data) override { return read_byte(a_register, data); } bool spa_write_byte(uint8_t a_register, uint8_t data) override { return write_byte(a_register, data); } diff --git a/esphome/components/spa06_spi/spa06_spi.h b/esphome/components/spa06_spi/spa06_spi.h index ffbc162d6f..56d72df620 100644 --- a/esphome/components/spa06_spi/spa06_spi.h +++ b/esphome/components/spa06_spi/spa06_spi.h @@ -5,9 +5,9 @@ namespace esphome::spa06_spi { -class SPA06SPIComponent : public spa06_base::SPA06Component, - public spi::SPIDevice { +class SPA06SPIComponent final : public spa06_base::SPA06Component, + public spi::SPIDevice { void setup() override; bool spa_read_byte(uint8_t a_register, uint8_t *data) override; bool spa_write_byte(uint8_t a_register, uint8_t data) override; diff --git a/esphome/components/speaker/automation.h b/esphome/components/speaker/automation.h index 9997b064d5..443588a04c 100644 --- a/esphome/components/speaker/automation.h +++ b/esphome/components/speaker/automation.h @@ -7,7 +7,7 @@ namespace esphome::speaker { -template class PlayAction : public Action, public Parented { +template class PlayAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -38,12 +38,12 @@ template class PlayAction : public Action, public Parente } data_; }; -template class VolumeSetAction : public Action, public Parented { +template class VolumeSetAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->set_volume(this->volume_.value(x...)); } }; -template class MuteOnAction : public Action { +template class MuteOnAction final : public Action { public: explicit MuteOnAction(Speaker *speaker) : speaker_(speaker) {} @@ -53,7 +53,7 @@ template class MuteOnAction : public Action { Speaker *speaker_; }; -template class MuteOffAction : public Action { +template class MuteOffAction final : public Action { public: explicit MuteOffAction(Speaker *speaker) : speaker_(speaker) {} @@ -63,22 +63,22 @@ template class MuteOffAction : public Action { Speaker *speaker_; }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop(); } }; -template class FinishAction : public Action, public Parented { +template class FinishAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->finish(); } }; -template class IsPlayingCondition : public Condition, public Parented { +template class IsPlayingCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running(); } }; -template class IsStoppedCondition : public Condition, public Parented { +template class IsStoppedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_stopped(); } }; diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 89f4707ab3..02dad15de9 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -56,7 +56,7 @@ struct InfoErrorEvent { optional decoding_err; }; -class AudioPipeline { +class AudioPipeline final { public: /// @param speaker ESPHome speaker component for pipeline's audio output /// @param buffer_size Size of the buffer in bytes between the reader and decoder diff --git a/esphome/components/speaker/media_player/automation.h b/esphome/components/speaker/media_player/automation.h index 7843399866..f9e2127993 100644 --- a/esphome/components/speaker/media_player/automation.h +++ b/esphome/components/speaker/media_player/automation.h @@ -9,7 +9,8 @@ namespace esphome::speaker { -template class PlayOnDeviceMediaAction : public Action, public Parented { +template +class PlayOnDeviceMediaAction final : public Action, public Parented { TEMPLATABLE_VALUE(audio::AudioFile *, audio_file) TEMPLATABLE_VALUE(bool, announcement) TEMPLATABLE_VALUE(bool, enqueue) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 7d9cfecfdf..fe994f440d 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -612,7 +612,7 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { } // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true); } else { this->set_mute_state_(false); diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 2d80377312..6470fb925c 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -42,11 +42,11 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerMediaPlayer : public Component, - public media_player::MediaPlayer +class SpeakerMediaPlayer final : public Component, + public media_player::MediaPlayer #ifdef USE_OTA_STATE_LISTENER , - public ota::OTAGlobalStateListener + public ota::OTAGlobalStateListener #endif { public: diff --git a/esphome/components/speaker_source/automation.h b/esphome/components/speaker_source/automation.h index b436149a03..a03fa42477 100644 --- a/esphome/components/speaker_source/automation.h +++ b/esphome/components/speaker_source/automation.h @@ -9,7 +9,7 @@ namespace esphome::speaker_source { -template class SetPlaylistDelayAction : public Action { +template class SetPlaylistDelayAction final : public Action { public: explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {} diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 87fd4fe9ed..a33a1a1650 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -831,7 +831,7 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { // Turn on the mute state if the volume is effectively zero, off otherwise. // Pass publish=false to avoid saving twice. - if (volume < 0.001) { + if (volume < 0.001f) { this->set_mute_state_(true, false); } else { this->set_mute_state_(false, false); diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index 652390edd2..ab1f8edfed 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -146,7 +146,7 @@ struct VolumeRestoreState { bool is_muted; }; -class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPlayer { +class SpeakerSourceMediaPlayer final : public Component, public media_player::MediaPlayer { friend struct SourceBinding; public: diff --git a/esphome/components/speed/fan/speed_fan.h b/esphome/components/speed/fan/speed_fan.h index c618d6bc5f..510b3e9621 100644 --- a/esphome/components/speed/fan/speed_fan.h +++ b/esphome/components/speed/fan/speed_fan.h @@ -7,7 +7,7 @@ namespace esphome::speed { -class SpeedFan : public Component, public fan::Fan { +class SpeedFan final : public Component, public fan::Fan { public: SpeedFan(int speed_count) : speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index 33ccfbb5ee..608adc7514 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -34,7 +35,7 @@ from esphome.const import ( KEY_VARIANT, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -53,7 +54,7 @@ SPIMode = spi_ns.enum("SPIMode") PLATFORM_SPI_CLOCKS = { PLATFORM_ESP8266: 40e6, PLATFORM_ESP32: 80e6, - PLATFORM_RP2040: 62.5e6, + PLATFORM_RP2: 62.5e6, } MAX_DATA_RATE_ERROR = 0.05 # Max allowable actual data rate difference from requested @@ -174,10 +175,11 @@ def get_hw_interface_list(): VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H21, ]: return [["spi", "spi2"]] return [["spi", "spi2"], ["spi3"]] - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: return [["spi"], ["spi1"]] return [] @@ -245,7 +247,7 @@ def validate_hw_pins(spi, index=-1): if target_platform == PLATFORM_ESP32: return clk_pin_no >= 0 - if target_platform == PLATFORM_RP2040: + if target_platform == PLATFORM_RP2: if index == -1: matches = list( filter(lambda s: clk_pin_no in s[CONF_CLK_PIN], RP_SPI_PINSETS) @@ -321,7 +323,7 @@ def get_spi_interface(index): # ESP32 uses ESP-IDF SPI driver for both Arduino and IDF frameworks return ["SPI2_HOST", "SPI3_HOST"][index] # Arduino code follows - if platform == PLATFORM_RP2040: + if platform == PLATFORM_RP2: return ["&SPI", "&SPI1"][index] if index == 0: return "&SPI" @@ -347,7 +349,7 @@ SPI_SINGLE_SCHEMA = cv.All( } ), cv.has_at_least_one_key(CONF_MISO_PIN, CONF_MOSI_PIN), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2]), ) @@ -498,7 +500,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( { "spi_arduino.cpp": { PlatformFramework.ESP8266_ARDUINO, - PlatformFramework.RP2040_ARDUINO, + PlatformFramework.RP2_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, diff --git a/esphome/components/spi/spi.h b/esphome/components/spi/spi.h index e6f592c6e4..c038426f61 100644 --- a/esphome/components/spi/spi.h +++ b/esphome/components/spi/spi.h @@ -17,7 +17,7 @@ using SPIInterface = spi_host_device_t; #include -#ifdef USE_RP2040 +#ifdef USE_RP2 using SPIInterface = SPIClassRP2040 *; #else using SPIInterface = SPIClass *; @@ -334,7 +334,7 @@ class SPIBus { class SPIClient; -class SPIComponent : public Component { +class SPIComponent final : public Component { public: SPIDelegate *register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate, GPIOPin *cs_pin, bool release_device, bool write_only); diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 4267fe63ce..a3e09d2800 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -11,7 +11,7 @@ class SPIDelegateHw : public SPIDelegate { : SPIDelegate(data_rate, bit_order, mode, cs_pin), channel_(channel) {} void begin_transaction() override { -#ifdef USE_RP2040 +#ifdef USE_RP2 SPISettings const settings(this->data_rate_, static_cast(this->bit_order_), this->mode_); #elif defined(ESP8266) // Arduino ESP8266 library has mangled values for SPI modes :-( @@ -41,7 +41,7 @@ class SPIDelegateHw : public SPIDelegate { this->channel_->transfer(*ptr); return; } -#ifdef USE_RP2040 +#ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be @@ -75,7 +75,7 @@ class SPIBusHw : public SPIBus { #ifdef USE_ESP32 channel->begin(Utility::get_pin_no(clk), Utility::get_pin_no(sdi), Utility::get_pin_no(sdo), -1); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 if (Utility::get_pin_no(sdi) != -1) channel->setRX(Utility::get_pin_no(sdi)); if (Utility::get_pin_no(sdo) != -1) diff --git a/esphome/components/spi_device/spi_device.h b/esphome/components/spi_device/spi_device.h index 3a2523fbab..506090fc58 100644 --- a/esphome/components/spi_device/spi_device.h +++ b/esphome/components/spi_device/spi_device.h @@ -5,9 +5,9 @@ namespace esphome::spi_device { -class SPIDeviceComponent : public Component, - public spi::SPIDevice { +class SPIDeviceComponent final : public Component, + public spi::SPIDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index e2bcd5af63..20b9c25c2e 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -8,9 +8,9 @@ namespace esphome::spi_led_strip { static const char *const TAG = "spi_led_strip"; -class SpiLedStrip : public light::AddressableLight, - public spi::SPIDevice { +class SpiLedStrip final : public light::AddressableLight, + public spi::SPIDevice { public: SpiLedStrip(uint16_t num_leds); void setup() override; diff --git a/esphome/components/sprinkler/automation.h b/esphome/components/sprinkler/automation.h index c6fe2e4e02..beeec96b98 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,7 +6,7 @@ namespace esphome::sprinkler { -template class SetDividerAction : public Action { +template class SetDividerAction final : public Action { public: explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -18,7 +18,7 @@ template class SetDividerAction : public Action { Sprinkler *sprinkler_; }; -template class SetMultiplierAction : public Action { +template class SetMultiplierAction final : public Action { public: explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -30,7 +30,7 @@ template class SetMultiplierAction : public Action { Sprinkler *sprinkler_; }; -template class QueueValveAction : public Action { +template class QueueValveAction final : public Action { public: explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -46,7 +46,7 @@ template class QueueValveAction : public Action { Sprinkler *sprinkler_; }; -template class ClearQueuedValvesAction : public Action { +template class ClearQueuedValvesAction final : public Action { public: explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -56,7 +56,7 @@ template class ClearQueuedValvesAction : public Action { Sprinkler *sprinkler_; }; -template class SetRepeatAction : public Action { +template class SetRepeatAction final : public Action { public: explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -68,7 +68,7 @@ template class SetRepeatAction : public Action { Sprinkler *sprinkler_; }; -template class SetRunDurationAction : public Action { +template class SetRunDurationAction final : public Action { public: explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -84,7 +84,7 @@ template class SetRunDurationAction : public Action { Sprinkler *sprinkler_; }; -template class StartFromQueueAction : public Action { +template class StartFromQueueAction final : public Action { public: explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -94,7 +94,7 @@ template class StartFromQueueAction : public Action { Sprinkler *sprinkler_; }; -template class StartFullCycleAction : public Action { +template class StartFullCycleAction final : public Action { public: explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -104,7 +104,7 @@ template class StartFullCycleAction : public Action { Sprinkler *sprinkler_; }; -template class StartSingleValveAction : public Action { +template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -122,7 +122,7 @@ template class StartSingleValveAction : public Action { TemplatableValue valve_to_start_{}; }; -template class ShutdownAction : public Action { +template class ShutdownAction final : public Action { public: explicit ShutdownAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -132,7 +132,7 @@ template class ShutdownAction : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction : public Action { +template class NextValveAction final : public Action { public: explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -142,7 +142,7 @@ template class NextValveAction : public Action { Sprinkler *sprinkler_; }; -template class PreviousValveAction : public Action { +template class PreviousValveAction final : public Action { public: explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -152,7 +152,7 @@ template class PreviousValveAction : public Action { Sprinkler *sprinkler_; }; -template class PauseAction : public Action { +template class PauseAction final : public Action { public: explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -162,7 +162,7 @@ template class PauseAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeAction : public Action { +template class ResumeAction final : public Action { public: explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -172,7 +172,7 @@ template class ResumeAction : public Action { Sprinkler *sprinkler_; }; -template class ResumeOrStartAction : public Action { +template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2598a5606a..bd610f7ad3 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -70,7 +70,7 @@ struct SprinklerValve { std::unique_ptr> valve_turn_on_automation; }; -class SprinklerControllerNumber : public number::Number, public Component { +class SprinklerControllerNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; @@ -89,7 +89,7 @@ class SprinklerControllerNumber : public number::Number, public Component { ESPPreferenceObject pref_; }; -class SprinklerControllerSwitch : public switch_::Switch, public Component { +class SprinklerControllerSwitch final : public switch_::Switch, public Component { public: SprinklerControllerSwitch(); @@ -173,7 +173,7 @@ class SprinklerValveRunRequest { SprinklerValveRunRequestOrigin origin_{USER}; }; -class Sprinkler : public Component { +class Sprinkler final : public Component { public: Sprinkler(); Sprinkler(const char *name); diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index e58f857eb3..ba978e7770 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -6,17 +6,17 @@ namespace esphome::sps30 { -template class StartFanAction : public Action, public Parented { +template class StartFanAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } }; -template class StartMeasurementAction : public Action, public Parented { +template class StartMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->start_measurement(); } }; -template class StopMeasurementAction : public Action, public Parented { +template class StopMeasurementAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index ccb3e8ff41..10b89c844b 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -8,7 +8,7 @@ namespace esphome::sps30 { /// This class implements support for the Sensirion SPS30 i2c/UART Particulate Matter /// PM1.0, PM2.5, PM4, PM10 Air Quality sensors. -class SPS30Component : public PollingComponent, public sensirion_common::SensirionI2CDevice { +class SPS30Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { public: void set_pm_1_0_sensor(sensor::Sensor *pm_1_0) { pm_1_0_sensor_ = pm_1_0; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5) { pm_2_5_sensor_ = pm_2_5; } diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.h b/esphome/components/ssd1306_i2c/ssd1306_i2c.h index 0316da0e77..54c7d86287 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.h +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1306_i2c { -class I2CSSD1306 : public ssd1306_base::SSD1306, public i2c::I2CDevice { +class I2CSSD1306 final : public ssd1306_base::SSD1306, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.h b/esphome/components/ssd1306_spi/ssd1306_spi.h index f8346033b3..948d099d0f 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.h +++ b/esphome/components/ssd1306_spi/ssd1306_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1306_spi { -class SPISSD1306 : public ssd1306_base::SSD1306, - public spi::SPIDevice { +class SPISSD1306 final : public ssd1306_base::SSD1306, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.h b/esphome/components/ssd1322_spi/ssd1322_spi.h index 31d17d0ef1..1ac9654109 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.h +++ b/esphome/components/ssd1322_spi/ssd1322_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1322_spi { -class SPISSD1322 : public ssd1322_base::SSD1322, - public spi::SPIDevice { +class SPISSD1322 final : public ssd1322_base::SSD1322, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.h b/esphome/components/ssd1325_spi/ssd1325_spi.h index 32cbb28fd8..3202eabec5 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.h +++ b/esphome/components/ssd1325_spi/ssd1325_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1325_spi { -class SPISSD1325 : public ssd1325_base::SSD1325, - public spi::SPIDevice { +class SPISSD1325 final : public ssd1325_base::SSD1325, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.h b/esphome/components/ssd1327_i2c/ssd1327_i2c.h index f08ef94fef..75f854d3da 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.h +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.h @@ -6,7 +6,7 @@ namespace esphome::ssd1327_i2c { -class I2CSSD1327 : public ssd1327_base::SSD1327, public i2c::I2CDevice { +class I2CSSD1327 final : public ssd1327_base::SSD1327, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.h b/esphome/components/ssd1327_spi/ssd1327_spi.h index fd1ed0357f..cb7d5e2181 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.h +++ b/esphome/components/ssd1327_spi/ssd1327_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1327_spi { -class SPISSD1327 : public ssd1327_base::SSD1327, - public spi::SPIDevice { +class SPISSD1327 final : public ssd1327_base::SSD1327, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.h b/esphome/components/ssd1331_spi/ssd1331_spi.h index acdc004b26..add010712c 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.h +++ b/esphome/components/ssd1331_spi/ssd1331_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1331_spi { -class SPISSD1331 : public ssd1331_base::SSD1331, - public spi::SPIDevice { +class SPISSD1331 final : public ssd1331_base::SSD1331, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.h b/esphome/components/ssd1351_spi/ssd1351_spi.h index 5ce41c1f9e..307807d19f 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.h +++ b/esphome/components/ssd1351_spi/ssd1351_spi.h @@ -6,9 +6,9 @@ namespace esphome::ssd1351_spi { -class SPISSD1351 : public ssd1351_base::SSD1351, - public spi::SPIDevice { +class SPISSD1351 final : public ssd1351_base::SSD1351, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7123/__init__.py b/esphome/components/st7123/__init__.py new file mode 100644 index 0000000000..335bc238be --- /dev/null +++ b/esphome/components/st7123/__init__.py @@ -0,0 +1,6 @@ +import esphome.codegen as cg + +CODEOWNERS = ["@miniskipper"] +DEPENDENCIES = ["i2c"] + +st7123_ns = cg.esphome_ns.namespace("st7123") diff --git a/esphome/components/st7123/touchscreen/__init__.py b/esphome/components/st7123/touchscreen/__init__.py new file mode 100644 index 0000000000..5ebd08066f --- /dev/null +++ b/esphome/components/st7123/touchscreen/__init__.py @@ -0,0 +1,32 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN + +from .. import st7123_ns + +ST7123Touchscreen = st7123_ns.class_( + "ST7123Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(ST7123Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } +).extend(i2c.i2c_device_schema(0x55)) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp new file mode 100644 index 0000000000..117f975264 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -0,0 +1,108 @@ +#include "st7123_touchscreen.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::st7123 { + +static const char *const TAG = "st7123.touchscreen"; + +void ST7123Touchscreen::setup() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(5); + this->reset_pin_->digital_write(false); // TP_RESX is active low, assert for at least tRSTW (2ms) + delay(5); + this->reset_pin_->digital_write(true); + // The controller needs up to 20ms to initialize after reset before it can be accessed. + this->setup_time_ = millis() + 30; + } +} + +void ST7123Touchscreen::update() { + // check if setup is complete + if (this->setup_time_ != 0) { + if (this->setup_time_ > millis()) + return; + + uint8_t status; + if (this->read_register16(ST7123_REG_STATUS, &status, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to read status register")); // will stop updates + return; + } + if ((status & 0x0F) == ST7123_STATUS_INIT) { + ESP_LOGD(TAG, "Controller still initializing"); + return; + } + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // INT is held high when idle and pulses low when touch data is ready. + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + ESP_LOGD(TAG, "Status is %X", status); + + uint8_t data; + if (this->read_register16(ST7123_REG_MAX_TOUCHES, &data, 1) == i2c::ERROR_OK && data != 0 && + data <= ST7123_MAX_TOUCHES) { + this->max_touches_ = data; + } + + // If no calibration was supplied, read the native coordinate resolution from the controller. + if (this->x_raw_max_ == this->x_raw_min_ || this->y_raw_max_ == this->y_raw_min_) { + uint8_t res[4]; + if (this->read_register16(ST7123_REG_MAX_X, res, sizeof(res)) == i2c::ERROR_OK) { + this->x_raw_max_ = encode_uint16(res[0] & ST7123_COORD_HIGH_MASK, res[1]); + this->y_raw_max_ = encode_uint16(res[2] & ST7123_COORD_HIGH_MASK, res[3]); + if (this->swap_x_y_) + std::swap(this->x_raw_max_, this->y_raw_max_); + } else { + this->mark_failed(LOG_STR("Failed to read calibration")); + return; + } + ESP_LOGD(TAG, "Read dimensions %d/%d", this->x_raw_max_, this->y_raw_max_); + } + this->setup_time_ = 0; // flag setup complete + } + Touchscreen::update(); +} + +void ST7123Touchscreen::update_touches() { + // Read the reporting table from the advanced touch info register through the last touch point. + // Reading from this register also clears the INT pin so the controller can report the next frame. + uint8_t data[(ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + ST7123_MAX_TOUCHES * ST7123_TOUCH_STRIDE]; + const size_t len = (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO) + this->max_touches_ * ST7123_TOUCH_STRIDE; + if (this->read_register16(ST7123_REG_ADV_TOUCH_INFO, data, len) != i2c::ERROR_OK) { + this->skip_update_ = true; + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + const uint8_t *points = data + (ST7123_REG_TOUCH_DATA - ST7123_REG_ADV_TOUCH_INFO); + for (uint8_t i = 0; i != this->max_touches_; i++) { + const uint8_t *p = points + i * ST7123_TOUCH_STRIDE; + if ((p[0] & ST7123_TOUCH_VALID) == 0) + continue; + uint16_t x = encode_uint16(p[0] & ST7123_COORD_HIGH_MASK, p[1]); + uint16_t y = encode_uint16(p[2] & ST7123_COORD_HIGH_MASK, p[3]); + uint8_t intensity = p[5]; + ESP_LOGV(TAG, "Touch %u: x=%u, y=%u, intensity=%u", i, x, y, intensity); + this->add_raw_touch_position_(i, x, y, intensity); + } +} + +void ST7123Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "ST7123 Touchscreen:\n" + " Max touches: %u\n" + " X Raw Min: %d, X Raw Max: %d\n" + " Y Raw Min: %d, Y Raw Max: %d", + this->max_touches_, this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::st7123 diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.h b/esphome/components/st7123/touchscreen/st7123_touchscreen.h new file mode 100644 index 0000000000..633eba7a82 --- /dev/null +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.h @@ -0,0 +1,48 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::st7123 { + +// Sitronix ST7123 capacitive touch controller. +// Registers are addressed with a 16-bit big-endian address (sent MSB first). +static constexpr uint16_t ST7123_REG_STATUS = 0x0001; // [7:4] error code, [3:0] device status +static constexpr uint16_t ST7123_REG_MAX_X = 0x0005; // 0x0005..0x0006 X resolution, 0x0007..0x0008 Y resolution +static constexpr uint16_t ST7123_REG_MAX_TOUCHES = 0x0009; +static constexpr uint16_t ST7123_REG_ADV_TOUCH_INFO = 0x0010; // start of the reporting table +static constexpr uint16_t ST7123_REG_TOUCH_DATA = 0x0014; // first touch point + +// Device status field of the status register. +static constexpr uint8_t ST7123_STATUS_INIT = 0x1; + +// Each touch point occupies 7 bytes: X high, X low, Y high, Y low, area, intensity, reserved. +static constexpr uint8_t ST7123_TOUCH_STRIDE = 7; +// Bit 7 of the X high byte indicates a valid touch point. +static constexpr uint8_t ST7123_TOUCH_VALID = 0x80; +// The X and Y high bytes only use the low 6 bits. +static constexpr uint8_t ST7123_COORD_HIGH_MASK = 0x3F; +// The ST7123 can report at most 10 touch points. +static constexpr uint8_t ST7123_MAX_TOUCHES = 10; + +class ST7123Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint8_t max_touches_{ST7123_MAX_TOUCHES}; + uint32_t setup_time_{1}; +}; + +} // namespace esphome::st7123 diff --git a/esphome/components/st7567_i2c/st7567_i2c.h b/esphome/components/st7567_i2c/st7567_i2c.h index 49489d79e6..eea3068e03 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.h +++ b/esphome/components/st7567_i2c/st7567_i2c.h @@ -6,7 +6,7 @@ namespace esphome::st7567_i2c { -class I2CST7567 : public st7567_base::ST7567, public i2c::I2CDevice { +class I2CST7567 final : public st7567_base::ST7567, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/st7567_spi/st7567_spi.h b/esphome/components/st7567_spi/st7567_spi.h index fb6f9501a9..e4699437ad 100644 --- a/esphome/components/st7567_spi/st7567_spi.h +++ b/esphome/components/st7567_spi/st7567_spi.h @@ -6,9 +6,9 @@ namespace esphome::st7567_spi { -class SPIST7567 : public st7567_base::ST7567, - public spi::SPIDevice { +class SPIST7567 final : public st7567_base::ST7567, + public spi::SPIDevice { public: void set_dc_pin(GPIOPin *dc_pin) { dc_pin_ = dc_pin; } diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index c65a213929..d44f8c6859 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -26,9 +26,9 @@ const uint8_t CMD2_BKSEL = 0xFF; const uint8_t CMD2_BK0[5] = {0x77, 0x01, 0x00, 0x00, 0x10}; const uint8_t ST7701S_DELAY_FLAG = 0xFF; -class ST7701S : public display::Display, - public spi::SPIDevice { +class ST7701S final : public display::Display, + public spi::SPIDevice { public: void update() override { this->do_update_(); } void setup() override; diff --git a/esphome/components/st7735/st7735.h b/esphome/components/st7735/st7735.h index 7fa0ad7335..28bc0916f9 100644 --- a/esphome/components/st7735/st7735.h +++ b/esphome/components/st7735/st7735.h @@ -31,9 +31,9 @@ enum ST7735Model { ST7735_INITR_18REDTAB = INITR_18REDTAB }; -class ST7735 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7735 final : public display::DisplayBuffer, + public spi::SPIDevice { public: ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors); diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 3f9942b117..1b7ba318a6 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -106,9 +106,9 @@ static const uint8_t ST7789_MADCTL_GS = 0x01; static const uint8_t ST7789_MADCTL_COLOR_ORDER = ST7789_MADCTL_BGR; -class ST7789V : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7789V final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_model_str(const char *model_str); void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } diff --git a/esphome/components/st7920/st7920.h b/esphome/components/st7920/st7920.h index 71fe7aa89c..0160c5270f 100644 --- a/esphome/components/st7920/st7920.h +++ b/esphome/components/st7920/st7920.h @@ -10,9 +10,9 @@ class ST7920; using st7920_writer_t = display::DisplayWriter; -class ST7920 : public display::DisplayBuffer, - public spi::SPIDevice { +class ST7920 final : public display::DisplayBuffer, + public spi::SPIDevice { public: void set_writer(st7920_writer_t &&writer) { this->writer_local_ = writer; } void set_height(uint16_t height) { this->height_ = height; } diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 77f3d797c5..7cbde6d743 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -27,7 +27,7 @@ namespace esphome::statsd { -class StatsdComponent : public PollingComponent { +class StatsdComponent final : public PollingComponent { public: ~StatsdComponent(); diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 7e8c31d741..28cf4cd083 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::status { -class StatusBinarySensor : public binary_sensor::BinarySensor, public PollingComponent { +class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: void update() override; diff --git a/esphome/components/status_led/light/status_led_light.h b/esphome/components/status_led/light/status_led_light.h index 0483669d0a..5eb0d3c085 100644 --- a/esphome/components/status_led/light/status_led_light.h +++ b/esphome/components/status_led/light/status_led_light.h @@ -7,7 +7,7 @@ namespace esphome::status_led { -class StatusLEDLightOutput : public light::LightOutput, public Component { +class StatusLEDLightOutput final : public light::LightOutput, public Component { public: void set_pin(GPIOPin *pin) { pin_ = pin; } void set_output(output::BinaryOutput *output) { output_ = output; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index bda144d2cd..3688dba8d6 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -5,7 +5,7 @@ namespace esphome::status_led { -class StatusLED : public Component { +class StatusLED final : public Component { public: explicit StatusLED(GPIOPin *pin); diff --git a/esphome/components/stepper/stepper.h b/esphome/components/stepper/stepper.h index 9fbd0d92e6..06ef3bab37 100644 --- a/esphome/components/stepper/stepper.h +++ b/esphome/components/stepper/stepper.h @@ -37,7 +37,7 @@ class Stepper { uint32_t last_step_{0}; }; -template class SetTargetAction : public Action { +template class SetTargetAction final : public Action { public: explicit SetTargetAction(Stepper *parent) : parent_(parent) {} @@ -49,7 +49,7 @@ template class SetTargetAction : public Action { Stepper *parent_; }; -template class ReportPositionAction : public Action { +template class ReportPositionAction final : public Action { public: explicit ReportPositionAction(Stepper *parent) : parent_(parent) {} @@ -61,7 +61,7 @@ template class ReportPositionAction : public Action { Stepper *parent_; }; -template class SetSpeedAction : public Action { +template class SetSpeedAction final : public Action { public: explicit SetSpeedAction(Stepper *parent) : parent_(parent) {} @@ -77,7 +77,7 @@ template class SetSpeedAction : public Action { Stepper *parent_; }; -template class SetAccelerationAction : public Action { +template class SetAccelerationAction final : public Action { public: explicit SetAccelerationAction(Stepper *parent) : parent_(parent) {} @@ -92,7 +92,7 @@ template class SetAccelerationAction : public Action { Stepper *parent_; }; -template class SetDecelerationAction : public Action { +template class SetDecelerationAction final : public Action { public: explicit SetDecelerationAction(Stepper *parent) : parent_(parent) {} diff --git a/esphome/components/sts3x/sts3x.h b/esphome/components/sts3x/sts3x.h index 038fa0dd80..6752cf689b 100644 --- a/esphome/components/sts3x/sts3x.h +++ b/esphome/components/sts3x/sts3x.h @@ -9,7 +9,9 @@ namespace esphome::sts3x { /// This class implements support for the ST3x-DIS family of temperature i2c sensors. -class STS3XComponent : public sensor::Sensor, public PollingComponent, public sensirion_common::SensirionI2CDevice { +class STS3XComponent final : public sensor::Sensor, + public PollingComponent, + public sensirion_common::SensirionI2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h index 442a263e49..d8d7a485cf 100644 --- a/esphome/components/stts22h/stts22h.h +++ b/esphome/components/stts22h/stts22h.h @@ -6,7 +6,7 @@ namespace esphome::stts22h { -class STTS22HComponent : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class STTS22HComponent final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/sun/sensor/sun_sensor.h b/esphome/components/sun/sensor/sun_sensor.h index 148e5297d9..bec1a1af67 100644 --- a/esphome/components/sun/sensor/sun_sensor.h +++ b/esphome/components/sun/sensor/sun_sensor.h @@ -11,7 +11,7 @@ enum SensorType { SUN_SENSOR_AZIMUTH, }; -class SunSensor : public sensor::Sensor, public PollingComponent { +class SunSensor final : public sensor::Sensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_type(SensorType type) { type_ = type; } diff --git a/esphome/components/sun/sun.h b/esphome/components/sun/sun.h index 2999c93c71..ea9e05042d 100644 --- a/esphome/components/sun/sun.h +++ b/esphome/components/sun/sun.h @@ -51,7 +51,7 @@ struct HorizontalCoordinate { } // namespace internal -class Sun { +class Sun final { public: void set_time(time::RealTimeClock *time) { time_ = time; } time::RealTimeClock *get_time() const { return time_; } @@ -78,7 +78,7 @@ class Sun { internal::GeoLocation location_; }; -class SunTrigger : public Trigger<>, public PollingComponent, public Parented { +class SunTrigger final : public Trigger<>, public PollingComponent, public Parented { public: SunTrigger() : PollingComponent(60000) {} @@ -109,7 +109,7 @@ class SunTrigger : public Trigger<>, public PollingComponent, public Parented class SunCondition : public Condition, public Parented { +template class SunCondition final : public Condition, public Parented { public: TEMPLATABLE_VALUE(double, elevation); void set_above(bool above) { above_ = above; } diff --git a/esphome/components/sun/text_sensor/sun_text_sensor.h b/esphome/components/sun/text_sensor/sun_text_sensor.h index 65b0e358d0..a247a95e06 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.h +++ b/esphome/components/sun/text_sensor/sun_text_sensor.h @@ -8,7 +8,7 @@ namespace esphome::sun { -class SunTextSensor : public text_sensor::TextSensor, public PollingComponent { +class SunTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: void set_parent(Sun *parent) { parent_ = parent; } void set_elevation(double elevation) { elevation_ = elevation; } diff --git a/esphome/components/sun_gtil2/sun_gtil2.h b/esphome/components/sun_gtil2/sun_gtil2.h index e774fefcf8..dc3516f2b5 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.h +++ b/esphome/components/sun_gtil2/sun_gtil2.h @@ -15,7 +15,7 @@ namespace esphome::sun_gtil2 { -class SunGTIL2 : public Component, public uart::UARTDevice { +class SunGTIL2 final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/switch/__init__.py b/esphome/components/switch/__init__.py index 1108652e99..18b95113cc 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -78,7 +78,9 @@ _SWITCH_SCHEMA = ( cv.Optional(CONF_ON_STATE): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}), cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, } ) ) diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h index ed1f056c8b..158fb08baf 100644 --- a/esphome/components/switch/automation.h +++ b/esphome/components/switch/automation.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -template class TurnOnAction : public Action { +template class TurnOnAction final : public Action { public: explicit TurnOnAction(Switch *a_switch) : switch_(a_switch) {} @@ -16,7 +16,7 @@ template class TurnOnAction : public Action { Switch *switch_; }; -template class TurnOffAction : public Action { +template class TurnOffAction final : public Action { public: explicit TurnOffAction(Switch *a_switch) : switch_(a_switch) {} @@ -26,7 +26,7 @@ template class TurnOffAction : public Action { Switch *switch_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Switch *a_switch) : switch_(a_switch) {} @@ -36,7 +36,7 @@ template class ToggleAction : public Action { Switch *switch_; }; -template class ControlAction : public Action { +template class ControlAction final : public Action { public: explicit ControlAction(Switch *a_switch) : switch_(a_switch) {} @@ -53,7 +53,7 @@ template class ControlAction : public Action { Switch *switch_; }; -template class SwitchCondition : public Condition { +template class SwitchCondition final : public Condition { public: SwitchCondition(Switch *parent, bool state) : parent_(parent), state_(state) {} bool check(const Ts &...x) override { return this->parent_->state == this->state_; } @@ -63,14 +63,14 @@ template class SwitchCondition : public Condition { bool state_; }; -class SwitchStateTrigger : public Trigger { +class SwitchStateTrigger final : public Trigger { public: SwitchStateTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { this->trigger(state); }); } }; -class SwitchTurnOnTrigger : public Trigger<> { +class SwitchTurnOnTrigger final : public Trigger<> { public: SwitchTurnOnTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -81,7 +81,7 @@ class SwitchTurnOnTrigger : public Trigger<> { } }; -class SwitchTurnOffTrigger : public Trigger<> { +class SwitchTurnOffTrigger final : public Trigger<> { public: SwitchTurnOffTrigger(Switch *a_switch) { a_switch->add_on_state_callback([this](bool state) { @@ -92,7 +92,7 @@ class SwitchTurnOffTrigger : public Trigger<> { } }; -template class SwitchPublishAction : public Action { +template class SwitchPublishAction final : public Action { public: SwitchPublishAction(Switch *a_switch) : switch_(a_switch) {} TEMPLATABLE_VALUE(bool, state) diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.h b/esphome/components/switch/binary_sensor/switch_binary_sensor.h index 0b77cdd920..5c4184ecfa 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.h +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::switch_ { -class SwitchBinarySensor : public binary_sensor::BinarySensor, public Component { +class SwitchBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void set_source(Switch *source) { source_ = source; } void setup() override; diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index a4ba5c34f3..29e3ad5359 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -41,6 +41,8 @@ CONF_SPREADING_FACTOR = "spreading_factor" CONF_SYNC_VALUE = "sync_value" CONF_TCXO_VOLTAGE = "tcxo_voltage" CONF_TCXO_DELAY = "tcxo_delay" +CONF_WHITENING_ENABLE = "whitening_enable" +CONF_WHITENING_INITIAL = "whitening_initial" sx126x_ns = cg.esphome_ns.namespace("sx126x") SX126x = sx126x_ns.class_("SX126x", cg.Component, spi.SPIDevice) @@ -232,6 +234,10 @@ CONFIG_SCHEMA = ( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=262144000)), ), + cv.Optional(CONF_WHITENING_ENABLE, default=False): cv.boolean, + cv.Optional(CONF_WHITENING_INITIAL, default=0x0100): cv.All( + cv.hex_int, cv.Range(min=0, max=0x1FF) + ), }, ) .extend(cv.COMPONENT_SCHEMA) @@ -285,6 +291,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_rf_switch(config[CONF_RF_SWITCH])) cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE])) cg.add(var.set_tcxo_delay(config[CONF_TCXO_DELAY])) + cg.add(var.set_whitening_enable(config[CONF_WHITENING_ENABLE])) + cg.add(var.set_whitening_initial(config[CONF_WHITENING_INITIAL])) NO_ARGS_ACTION_SCHEMA = automation.maybe_simple_id( diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 2721cbfbbf..4eb33abaa1 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx126x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,23 +43,23 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(bool, cold) void play(const Ts &...x) override { this->parent_->set_mode_sleep(this->cold_.value(x...)); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(STDBY_XOSC); } }; diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 7590e35c28..ccd20755e5 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx126x { -class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { +class SX126xTransport final : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index aed0105e1f..376676ce85 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -215,7 +215,7 @@ void SX126x::configure() { // configure modem if (this->modulation_ == PACKET_TYPE_LORA) { // set modulation params - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; buf[0] = this->spreading_factor_; buf[1] = BW_LORA[this->bandwidth_ - SX126X_BW_7810]; buf[2] = this->coding_rate_; @@ -251,6 +251,16 @@ void SX126x::configure() { this->write_register_(REG_CRC_POLYNOMIAL, buf, 2); } + // set whitening params + if (this->whitening_enable_) { + // according to the datasheet, section 12 table 12-1 "The user should not + // change the value of the 7 MSB of this register" + this->read_register_(REG_WHITENING_INITIAL, buf, 1); + buf[0] = (buf[0] & 0xFE) | ((this->whitening_initial_ >> 8) & 0x01); + buf[1] = this->whitening_initial_ & 0xFF; + this->write_register_(REG_WHITENING_INITIAL, buf, 2); + } + // set packet params and sync word this->set_packet_params_(this->get_max_packet_size()); if (!this->sync_value_.empty()) { @@ -297,7 +307,7 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } else { buf[7] = 0x01; } - buf[8] = 0x00; + buf[8] = (this->whitening_enable_) ? 0x01 : 0x00; this->write_opcode_(RADIO_SET_PACKETPARAMS, buf, 9); } } @@ -541,6 +551,10 @@ void SX126x::dump_config() { ESP_LOGCONFIG(TAG, " Sync Value: 0x%s", format_hex_to(hex_buf, this->sync_value_.data(), this->sync_value_.size())); } + ESP_LOGCONFIG(TAG, " Whitening Enable: %s", TRUEFALSE(this->whitening_enable_)); + if (this->whitening_enable_) { + ESP_LOGCONFIG(TAG, " Whitening Initial: 0x%03x", this->whitening_initial_); + } if (this->is_failed()) { ESP_LOGE(TAG, "Configuring SX126x failed"); } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 8298beb36e..b3dfe6590a 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -53,9 +53,9 @@ class SX126xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX126x : public Component, - public spi::SPIDevice { +class SX126x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } @@ -71,6 +71,8 @@ class SX126x : public Component, void set_crc_size(uint8_t crc_size) { this->crc_size_ = crc_size; } void set_crc_polynomial(uint16_t crc_polynomial) { this->crc_polynomial_ = crc_polynomial; } void set_crc_initial(uint16_t crc_initial) { this->crc_initial_ = crc_initial; } + void set_whitening_enable(bool whitening_enable) { this->whitening_enable_ = whitening_enable; } + void set_whitening_initial(uint16_t whitening_initial) { this->whitening_initial_ = whitening_initial; } void set_deviation(uint32_t deviation) { this->deviation_ = deviation; } void set_dio1_pin(GPIOPin *dio1_pin) { this->dio1_pin_ = dio1_pin; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } @@ -128,6 +130,8 @@ class SX126x : public Component, uint8_t crc_size_{0}; uint16_t crc_polynomial_{0}; uint16_t crc_initial_{0}; + bool whitening_enable_{false}; + uint16_t whitening_initial_{0}; uint32_t deviation_{0}; uint32_t frequency_{0}; uint32_t payload_length_{0}; diff --git a/esphome/components/sx126x/sx126x_reg.h b/esphome/components/sx126x/sx126x_reg.h index c70817364f..197a2aaadb 100644 --- a/esphome/components/sx126x/sx126x_reg.h +++ b/esphome/components/sx126x/sx126x_reg.h @@ -52,6 +52,7 @@ enum SX126xOpCode : uint8_t { enum SX126xRegister : uint16_t { REG_VERSION_STRING = 0x0320, + REG_WHITENING_INITIAL = 0x06B8, REG_CRC_INITIAL = 0x06BC, REG_CRC_POLYNOMIAL = 0x06BE, REG_GFSK_SYNCWORD = 0x06C0, diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index 7a2eb7ee8d..f6a4537e23 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,12 +6,12 @@ namespace esphome::sx127x { -template class RunImageCalAction : public Action, public Parented { +template class RunImageCalAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->run_image_cal(); } }; -template class SendPacketAction : public Action, public Parented { +template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; @@ -43,22 +43,22 @@ template class SendPacketAction : public Action, public P } data_; }; -template class SetModeTxAction : public Action, public Parented { +template class SetModeTxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_tx(); } }; -template class SetModeRxAction : public Action, public Parented { +template class SetModeRxAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_rx(); } }; -template class SetModeSleepAction : public Action, public Parented { +template class SetModeSleepAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_sleep(); } }; -template class SetModeStandbyAction : public Action, public Parented { +template class SetModeStandbyAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->set_mode_standby(); } }; diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index 5dcfe02c33..fb38fc15bc 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -7,7 +7,7 @@ namespace esphome::sx127x { -class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { +class SX127xTransport final : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; void on_packet(const std::vector &packet, float rssi, float snr) override; diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 0596e91ccc..040a3064bc 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -201,8 +201,8 @@ void SX127x::configure_fsk_ook_() { this->write_register_(REG_OOK_AVG, OOK_AVG_RESERVED | OOK_THRESH_DEC_1_8); // set rx floor - this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0)); - this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0))); + this->write_register_(REG_OOK_FIX, 256 + int(this->rx_floor_ * 2.0f)); + this->write_register_(REG_RSSI_THRESH, std::abs(int(this->rx_floor_ * 2.0f))); } void SX127x::configure_lora_() { @@ -225,7 +225,7 @@ void SX127x::configure_lora_() { } // optimize detection - float duration = 1000.0f * std::pow(2, this->spreading_factor_) / BW_HZ[this->bandwidth_]; + float duration = 1000.0f * (1UL << this->spreading_factor_) / BW_HZ[this->bandwidth_]; if (duration > 16) { this->write_register_(REG_MODEM_CONFIG3, MODEM_AGC_AUTO_ON | LOW_DATA_RATE_OPTIMIZE_ON); } else { diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 376c987ed1..070a6eeb96 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -41,9 +41,9 @@ class SX127xListener { virtual void on_packet(const std::vector &packet, float rssi, float snr) = 0; }; -class SX127x : public Component, - public spi::SPIDevice { +class SX127x final : public Component, + public spi::SPIDevice { public: size_t get_max_packet_size(); float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h index bcd8901530..5d26a37283 100644 --- a/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h +++ b/esphome/components/sx1509/binary_sensor/sx1509_binary_keypad_sensor.h @@ -5,7 +5,7 @@ namespace esphome::sx1509 { -class SX1509BinarySensor : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { +class SX1509BinarySensor final : public sx1509::SX1509Processor, public binary_sensor::BinarySensor { public: void set_row_col(uint8_t row, uint8_t col) { this->key_ = (1 << (col + 8)) | (1 << row); } void process(uint16_t data) override { this->publish_state(static_cast(data == key_)); } diff --git a/esphome/components/sx1509/output/sx1509_float_output.h b/esphome/components/sx1509/output/sx1509_float_output.h index ee53cef637..8790b2fcd7 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.h +++ b/esphome/components/sx1509/output/sx1509_float_output.h @@ -7,7 +7,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509FloatOutputChannel : public output::FloatOutput, public Component { +class SX1509FloatOutputChannel final : public output::FloatOutput, public Component { public: void set_parent(SX1509Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 35883eed5b..c7aed2cddd 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -28,12 +28,12 @@ class SX1509Processor { virtual void process(uint16_t data){}; }; -class SX1509KeyTrigger : public Trigger {}; +class SX1509KeyTrigger final : public Trigger {}; -class SX1509Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander, - public key_provider::KeyProvider { +class SX1509Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander, + public key_provider::KeyProvider { public: SX1509Component() = default; diff --git a/esphome/components/sx1509/sx1509_gpio_pin.h b/esphome/components/sx1509/sx1509_gpio_pin.h index 9dcad37b27..3bd3d90bd9 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.h +++ b/esphome/components/sx1509/sx1509_gpio_pin.h @@ -6,7 +6,7 @@ namespace esphome::sx1509 { class SX1509Component; -class SX1509GPIOPin : public GPIOPin { +class SX1509GPIOPin final : public GPIOPin { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h index 4a374d7e3d..b94c89d123 100644 --- a/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h +++ b/esphome/components/sy6970/binary_sensor/sy6970_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { template -class StatusBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class StatusBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t value = (data.registers[REG] >> SHIFT) & MASK; @@ -24,7 +24,7 @@ class InverseStatusBinarySensor : public SY6970Listener, public binary_sensor::B }; // Custom binary sensor for charging (true when pre-charge or fast charge) -class SY6970ChargingBinarySensor : public SY6970Listener, public binary_sensor::BinarySensor { +class SY6970ChargingBinarySensor final : public SY6970Listener, public binary_sensor::BinarySensor { public: void on_data(const SY6970Data &data) override { uint8_t chrg_stat = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; diff --git a/esphome/components/sy6970/sensor/sy6970_sensor.h b/esphome/components/sy6970/sensor/sy6970_sensor.h index f912d726b2..61abbc3e36 100644 --- a/esphome/components/sy6970/sensor/sy6970_sensor.h +++ b/esphome/components/sy6970/sensor/sy6970_sensor.h @@ -34,7 +34,7 @@ using SY6970SystemVoltageSensor = VoltageSensor; // Precharge current sensor needs special handling (bit shift) -class SY6970PrechargeCurrentSensor : public SY6970Listener, public sensor::Sensor { +class SY6970PrechargeCurrentSensor final : public SY6970Listener, public sensor::Sensor { public: void on_data(const SY6970Data &data) override { uint8_t iprechg = (data.registers[SY6970_REG_PRECHARGE_CURRENT] >> 4) & 0x0F; diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 2225dd781b..06f0615ab4 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -73,7 +73,7 @@ class SY6970Listener { virtual void on_data(const SY6970Data &data) = 0; }; -class SY6970Component : public PollingComponent, public i2c::I2CDevice { +class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, uint16_t precharge_current, bool charge_enabled, bool enable_adc) diff --git a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h index 665c5eca64..e569bd0b90 100644 --- a/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h +++ b/esphome/components/sy6970/text_sensor/sy6970_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::sy6970 { // Bus status text sensor -class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970BusStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 5) & 0x07; @@ -40,7 +40,7 @@ class SY6970BusStatusTextSensor : public SY6970Listener, public text_sensor::Tex }; // Charge status text sensor -class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970ChargeStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = (data.registers[SY6970_REG_STATUS] >> 3) & 0x03; @@ -66,7 +66,7 @@ class SY6970ChargeStatusTextSensor : public SY6970Listener, public text_sensor:: }; // NTC status text sensor -class SY6970NtcStatusTextSensor : public SY6970Listener, public text_sensor::TextSensor { +class SY6970NtcStatusTextSensor final : public SY6970Listener, public text_sensor::TextSensor { public: void on_data(const SY6970Data &data) override { uint8_t status = data.registers[SY6970_REG_FAULT] & 0x07; diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index be4fa91436..4a76f9ac62 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -7,7 +7,7 @@ #ifdef USE_NETWORK namespace esphome::syslog { -class Syslog : public Component, public Parented { +class Syslog final : public Component, public Parented { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} void setup() override; diff --git a/esphome/components/t6615/t6615.h b/esphome/components/t6615/t6615.h index 0c2088f7b0..7ad2ae23c7 100644 --- a/esphome/components/t6615/t6615.h +++ b/esphome/components/t6615/t6615.h @@ -19,7 +19,7 @@ enum class T6615Command : uint8_t { SET_ELEVATION, }; -class T6615Component : public PollingComponent, public uart::UARTDevice { +class T6615Component final : public PollingComponent, public uart::UARTDevice { public: void loop() override; void update() override; diff --git a/esphome/components/tc74/tc74.h b/esphome/components/tc74/tc74.h index 4a53f39bc1..c48303c009 100644 --- a/esphome/components/tc74/tc74.h +++ b/esphome/components/tc74/tc74.h @@ -6,7 +6,7 @@ namespace esphome::tc74 { -class TC74Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TC74Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: /// Setup the sensor and check connection. void setup() override; diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index f0417ac7f7..a98c226d32 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -8,7 +8,7 @@ namespace esphome::tca9548a { static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; -class TCA9548AChannel : public i2c::I2CBus { +class TCA9548AChannel final : public i2c::I2CBus { public: void set_channel(uint8_t channel) { channel_ = channel; } void set_parent(TCA9548AComponent *parent) { parent_ = parent; } @@ -21,7 +21,7 @@ class TCA9548AChannel : public i2c::I2CBus { TCA9548AComponent *parent_; }; -class TCA9548AComponent : public Component, public i2c::I2CDevice { +class TCA9548AComponent final : public Component, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 19773a0e93..50037cbe92 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -7,9 +7,9 @@ namespace esphome::tca9555 { -class TCA9555Component : public Component, - public i2c::I2CDevice, - public gpio_expander::CachedGpioExpander { +class TCA9555Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { public: TCA9555Component() = default; @@ -47,7 +47,7 @@ class TCA9555Component : public Component, }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. -class TCA9555GPIOPin : public GPIOPin, public Parented { +class TCA9555GPIOPin final : public GPIOPin, public Parented { public: void setup() override; void pin_mode(gpio::Flags flags) override; diff --git a/esphome/components/tcl112/tcl112.h b/esphome/components/tcl112/tcl112.h index 0aef2decc8..21eb618947 100644 --- a/esphome/components/tcl112/tcl112.h +++ b/esphome/components/tcl112/tcl112.h @@ -8,7 +8,7 @@ namespace esphome::tcl112 { const float TCL112_TEMP_MAX = 31.0; const float TCL112_TEMP_MIN = 16.0; -class Tcl112Climate : public climate_ir::ClimateIR { +class Tcl112Climate final : public climate_ir::ClimateIR { public: Tcl112Climate() : climate_ir::ClimateIR(TCL112_TEMP_MIN, TCL112_TEMP_MAX, .5f, true, true, diff --git a/esphome/components/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index 40c65e9f84..b585392790 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -256,7 +256,7 @@ void TCS34725Component::update() { // increase only if not already maximum // do not use max gain, as ist will not get better if (this->gain_reg_ < 3) { - if (((float) raw_c / 655.35 < 20.f) && (this->integration_time_ > 600.f)) { + if (((float) raw_c / 655.35f < 20.f) && (this->integration_time_ > 600.f)) { gain_reg_val_new = this->gain_reg_ + 1; // update integration time to new situation integration_time_ideal = integration_time_ideal / 4; @@ -265,7 +265,7 @@ void TCS34725Component::update() { // decrease gain, if very high clear values and integration times alreadey low if (this->gain_reg_ > 0) { - if (70 < ((float) raw_c / 655.35) && (this->integration_time_ < 200)) { + if (70 < ((float) raw_c / 655.35f) && (this->integration_time_ < 200)) { gain_reg_val_new = this->gain_reg_ - 1; // update integration time to new situation integration_time_ideal = integration_time_ideal * 4; diff --git a/esphome/components/tcs34725/tcs34725.h b/esphome/components/tcs34725/tcs34725.h index 15e4fae52f..79b49bc810 100644 --- a/esphome/components/tcs34725/tcs34725.h +++ b/esphome/components/tcs34725/tcs34725.h @@ -35,7 +35,7 @@ enum TCS34725Gain { TCS34725_GAIN_60X = 0x03, }; -class TCS34725Component : public PollingComponent, public i2c::I2CDevice { +class TCS34725Component final : public PollingComponent, public i2c::I2CDevice { public: void set_integration_time(TCS34725IntegrationTime integration_time); void set_gain(TCS34725Gain gain); diff --git a/esphome/components/tee501/tee501.h b/esphome/components/tee501/tee501.h index 4a08291318..bbd63a4e2b 100644 --- a/esphome/components/tee501/tee501.h +++ b/esphome/components/tee501/tee501.h @@ -7,7 +7,7 @@ namespace esphome::tee501 { /// This class implements support for the tee501 of temperature i2c sensors. -class TEE501Component : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TEE501Component final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.h b/esphome/components/teleinfo/sensor/teleinfo_sensor.h index 37736c4e73..f4a27fa08b 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.h +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.h @@ -4,7 +4,7 @@ namespace esphome::teleinfo { -class TeleInfoSensor : public TeleInfoListener, public sensor::Sensor, public Component { +class TeleInfoSensor final : public TeleInfoListener, public sensor::Sensor, public Component { public: TeleInfoSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index eeab3b5103..83ea1474f2 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -20,7 +20,7 @@ class TeleInfoListener { std::string tag; virtual void publish_val(const std::string &val){}; }; -class TeleInfo : public PollingComponent, public uart::UARTDevice { +class TeleInfo final : public PollingComponent, public uart::UARTDevice { public: TeleInfo(bool historical_mode); void register_teleinfo_listener(TeleInfoListener *listener); diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h index f4c04a03a0..24ec00e671 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.h @@ -3,7 +3,7 @@ #include "esphome/components/text_sensor/text_sensor.h" namespace esphome::teleinfo { -class TeleInfoTextSensor : public TeleInfoListener, public text_sensor::TextSensor, public Component { +class TeleInfoTextSensor final : public TeleInfoListener, public text_sensor::TextSensor, public Component { public: TeleInfoTextSensor(const char *tag); void publish_val(const std::string &val) override; diff --git a/esphome/components/tem3200/tem3200.h b/esphome/components/tem3200/tem3200.h index 5c73a25fbb..ad8d0154f3 100644 --- a/esphome/components/tem3200/tem3200.h +++ b/esphome/components/tem3200/tem3200.h @@ -7,7 +7,7 @@ namespace esphome::tem3200 { /// This class implements support for the tem3200 pressure and temperature i2c sensors. -class TEM3200Component : public PollingComponent, public i2c::I2CDevice { +class TEM3200Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_raw_pressure_sensor(sensor::Sensor *raw_pressure_sensor) { diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h index 42a2a826e2..a979291b78 100644 --- a/esphome/components/template/lock/automation.h +++ b/esphome/components/template/lock/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateLockPublishAction : public Action, public Parented { +template class TemplateLockPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(lock::LockState, state) diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h index a27e98b25c..ec9d784ab6 100644 --- a/esphome/components/template/valve/automation.h +++ b/esphome/components/template/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { -template class TemplateValvePublishAction : public Action, public Parented { +template class TemplateValvePublishAction final : public Action, public Parented { TEMPLATABLE_VALUE(float, position) TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) diff --git a/esphome/components/template/water_heater/automation.h b/esphome/components/template/water_heater/automation.h index d19542db41..3301a15af1 100644 --- a/esphome/components/template/water_heater/automation.h +++ b/esphome/components/template/water_heater/automation.h @@ -6,7 +6,7 @@ namespace esphome::template_ { template -class TemplateWaterHeaterPublishAction : public Action, public Parented { +class TemplateWaterHeaterPublishAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, current_temperature) TEMPLATABLE_VALUE(float, target_temperature) diff --git a/esphome/components/template/water_heater/template_water_heater.h b/esphome/components/template/water_heater/template_water_heater.h index 045a142e40..7d5f198553 100644 --- a/esphome/components/template/water_heater/template_water_heater.h +++ b/esphome/components/template/water_heater/template_water_heater.h @@ -13,7 +13,7 @@ enum TemplateWaterHeaterRestoreMode { WATER_HEATER_RESTORE_AND_CALL, }; -class TemplateWaterHeater : public Component, public water_heater::WaterHeater { +class TemplateWaterHeater final : public Component, public water_heater::WaterHeater { public: TemplateWaterHeater(); diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h index ac8166d0be..916d86340d 100644 --- a/esphome/components/text/automation.h +++ b/esphome/components/text/automation.h @@ -6,14 +6,14 @@ namespace esphome::text { -class TextStateTrigger : public Trigger { +class TextStateTrigger final : public Trigger { public: explicit TextStateTrigger(Text *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSetAction : public Action { +template class TextSetAction final : public Action { public: explicit TextSetAction(Text *text) : text_(text) {} TEMPLATABLE_VALUE(std::string, value) diff --git a/esphome/components/text/text_sensor/text_text_sensor.h b/esphome/components/text/text_sensor/text_text_sensor.h index fd70ea3451..59fa04a75e 100644 --- a/esphome/components/text/text_sensor/text_text_sensor.h +++ b/esphome/components/text/text_sensor/text_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::text { -class TextTextSensor : public text_sensor::TextSensor, public Component { +class TextTextSensor final : public text_sensor::TextSensor, public Component { public: explicit TextTextSensor(Text *source) : source_(source) {} void setup() override; diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 01a57cbaa1..a3f4999a8f 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor), cv.GenerateID(): cv.declare_id(TextSensor), - cv.Optional(CONF_DEVICE_CLASS): validate_device_class, + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): validate_device_class, cv.Optional(CONF_FILTERS): validate_filters, cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}), diff --git a/esphome/components/text_sensor/automation.h b/esphome/components/text_sensor/automation.h index ab30362774..628b9b84a0 100644 --- a/esphome/components/text_sensor/automation.h +++ b/esphome/components/text_sensor/automation.h @@ -8,21 +8,21 @@ namespace esphome::text_sensor { -class TextSensorStateTrigger : public Trigger { +class TextSensorStateTrigger final : public Trigger { public: explicit TextSensorStateTrigger(TextSensor *parent) { parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -class TextSensorStateRawTrigger : public Trigger { +class TextSensorStateRawTrigger final : public Trigger { public: explicit TextSensorStateRawTrigger(TextSensor *parent) { parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); } }; -template class TextSensorStateCondition : public Condition { +template class TextSensorStateCondition final : public Condition { public: explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} @@ -34,7 +34,7 @@ template class TextSensorStateCondition : public Condition class TextSensorPublishAction : public Action { +template class TextSensorPublishAction final : public Action { public: TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} TEMPLATABLE_VALUE(std::string, state) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 1ccf59a2f6..2a950d3664 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -196,7 +196,7 @@ static optional parse_tp3(const uint8_t *data, std::size_t data_siz result.humidity = static_cast(data[3]); // battery level, 2 bits (0-2) - result.battery_level = static_cast(data[4] & 0x3) * 50.0; + result.battery_level = static_cast(data[4] & 0x3) * 50.0f; return result; } diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 38bed82102..2d7523e07a 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -17,7 +17,7 @@ struct ParseResult { using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); -class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; }; diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4268d5c582..f30659a8a6 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -81,7 +81,7 @@ struct ThermostatCustomPresetEntry { ThermostatClimateTargetTempConfig config; }; -class ThermostatClimate : public climate::Climate, public Component { +class ThermostatClimate final : public climate::Climate, public Component { public: using PresetEntry = ThermostatPresetEntry; using CustomPresetEntry = ThermostatCustomPresetEntry; diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index b3bf2d44d7..35fad0a450 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,11 +1,8 @@ import errno +import functools from importlib import resources import logging -from aioesphomeapi.posix_tz import ( - DSTRuleType as PyDSTRuleType, - parse_posix_tz as parse_posix_tz_python, -) import tzlocal from esphome import automation @@ -57,13 +54,20 @@ DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) DSTRule_cpp = time_ns.struct("DSTRule") ParsedTimezone_cpp = time_ns.struct("ParsedTimezone") -# Map Python DSTRuleType enum values to C++ enum expressions -_DST_RULE_TYPE_MAP = { - PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, - PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, - PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, - PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, -} + +# Map Python DSTRuleType enum values to C++ enum expressions. Built lazily to +# avoid importing aioesphomeapi (a heavy import) when the time component is only +# auto-loaded for its schema and never reaches code generation. +@functools.cache +def _dst_rule_type_map() -> dict: + from aioesphomeapi.posix_tz import DSTRuleType as PyDSTRuleType + + return { + PyDSTRuleType.NONE: DSTRuleType_cpp.NONE, + PyDSTRuleType.MONTH_WEEK_DAY: DSTRuleType_cpp.MONTH_WEEK_DAY, + PyDSTRuleType.JULIAN_NO_LEAP: DSTRuleType_cpp.JULIAN_NO_LEAP, + PyDSTRuleType.DAY_OF_YEAR: DSTRuleType_cpp.DAY_OF_YEAR, + } def _load_tzdata(iana_key: str) -> bytes | None: @@ -317,6 +321,8 @@ def validate_tz(value: str) -> str: # Validate that the POSIX TZ string is parseable (skip empty strings) if value: + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parse_posix_tz_python(value) except ValueError as e: @@ -372,7 +378,7 @@ def _emit_dst_rule_fields(prefix, rule): """Emit field-by-field assignments for a DSTRule to avoid rodata struct blob.""" cg.add(cg.RawExpression(f"{prefix}.time_seconds = {rule.time_seconds}")) cg.add(cg.RawExpression(f"{prefix}.day = {rule.day}")) - cg.add(cg.RawExpression(f"{prefix}.type = {_DST_RULE_TYPE_MAP[rule.type]}")) + cg.add(cg.RawExpression(f"{prefix}.type = {_dst_rule_type_map()[rule.type]}")) cg.add(cg.RawExpression(f"{prefix}.month = {rule.month}")) cg.add(cg.RawExpression(f"{prefix}.week = {rule.week}")) cg.add(cg.RawExpression(f"{prefix}.day_of_week = {rule.day_of_week}")) @@ -409,6 +415,8 @@ async def setup_time_core_(time_var, config): cg.add(time_var.set_timezone(timezone)) else: # Embedded: pre-parse at codegen time, emit struct directly + from aioesphomeapi.posix_tz import parse_posix_tz as parse_posix_tz_python + try: parsed = parse_posix_tz_python(timezone) _emit_parsed_timezone_fields(parsed) diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 546c4a10de..7be195903a 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -10,7 +10,7 @@ namespace esphome::time { -class CronTrigger : public Trigger<>, public Component { +class CronTrigger final : public Trigger<>, public Component { public: explicit CronTrigger(RealTimeClock *rtc); void add_second(uint8_t second); @@ -41,7 +41,7 @@ class CronTrigger : public Trigger<>, public Component { optional last_check_; }; -class SyncTrigger : public Trigger<>, public Component { +class SyncTrigger final : public Trigger<>, public Component { public: explicit SyncTrigger(RealTimeClock *rtc); diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4e623942ac..6a52348ae9 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -10,7 +10,7 @@ #ifdef USE_ESP8266 #include "sys/time.h" #endif -#if defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_RP2) || defined(USE_ZEPHYR) #include #endif #include diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 06ee2ea5af..7a9175f39c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -72,7 +72,7 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; -template class TimeHasTimeCondition : public Condition { +template class TimeHasTimeCondition final : public Condition { public: TimeHasTimeCondition(RealTimeClock *parent) : parent_(parent) {} bool check(const Ts &...x) override { return this->parent_->now().is_valid(); } diff --git a/esphome/components/time_based/cover/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h index ce0b105ceb..1e1f51ff23 100644 --- a/esphome/components/time_based/cover/time_based_cover.h +++ b/esphome/components/time_based/cover/time_based_cover.h @@ -6,7 +6,7 @@ namespace esphome::time_based { -class TimeBasedCover : public cover::Cover, public Component { +class TimeBasedCover final : public cover::Cover, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 0e02ff8724..9e1ad3afc4 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -2,9 +2,11 @@ from esphome import final_validate as fv import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_component, add_idf_sdkconfig_option, ) @@ -44,7 +46,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) @@ -64,7 +72,8 @@ def _final_validate(config): "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " "because both share the USB OTG peripheral. Set " "'logger.hardware_uart' to a hardware UART (e.g. UART0), or to " - "USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)" + "USB_SERIAL_JTAG on variants that support it " + "(ESP32-S3, ESP32-S31, ESP32-P4, ESP32-H4)" ) return config @@ -85,7 +94,7 @@ async def to_code(config): if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) - add_idf_component(name="espressif/esp_tinyusb", ref="2.1.1") + add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 567a84f8c3..b748959571 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "tinyusb_component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -61,4 +62,5 @@ void TinyUSB::dump_config() { } } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 56c33a708f..e85fea9d21 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "tinyusb.h" @@ -19,7 +20,7 @@ enum USBDStringDescriptor : uint8_t { static const char *const DEFAULT_USB_STR = "ESPHome"; -class TinyUSB : public Component { +class TinyUSB final : public Component { public: void setup() override; void dump_config() override; @@ -69,4 +70,5 @@ class TinyUSB : public Component { }; } // namespace esphome::tinyusb -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tlc59208f/tlc59208f_output.h b/esphome/components/tlc59208f/tlc59208f_output.h index 46f88de01f..678e309f59 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.h +++ b/esphome/components/tlc59208f/tlc59208f_output.h @@ -21,7 +21,7 @@ inline constexpr uint8_t TLC59208F_MODE2_WDT_35MS = (3 << 0); class TLC59208FOutput; -class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC59208FChannel final : public output::FloatOutput, public Parented { public: void set_channel(uint8_t channel) { channel_ = channel; } @@ -34,7 +34,7 @@ class TLC59208FChannel : public output::FloatOutput, public Parented { +class TLC5947Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5947/tlc5947.h b/esphome/components/tlc5947/tlc5947.h index 18acffa25f..a9519d784f 100644 --- a/esphome/components/tlc5947/tlc5947.h +++ b/esphome/components/tlc5947/tlc5947.h @@ -9,7 +9,7 @@ namespace esphome::tlc5947 { -class TLC5947 : public Component { +class TLC5947 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 24; diff --git a/esphome/components/tlc5971/output/tlc5971_output.h b/esphome/components/tlc5971/output/tlc5971_output.h index 2a24a19b6c..fd0f5e82b5 100644 --- a/esphome/components/tlc5971/output/tlc5971_output.h +++ b/esphome/components/tlc5971/output/tlc5971_output.h @@ -8,7 +8,7 @@ namespace esphome::tlc5971 { -class TLC5971Channel : public output::FloatOutput, public Parented { +class TLC5971Channel final : public output::FloatOutput, public Parented { public: void set_channel(uint16_t channel) { this->channel_ = channel; } diff --git a/esphome/components/tlc5971/tlc5971.h b/esphome/components/tlc5971/tlc5971.h index 080249c89c..75e4c57027 100644 --- a/esphome/components/tlc5971/tlc5971.h +++ b/esphome/components/tlc5971/tlc5971.h @@ -9,7 +9,7 @@ namespace esphome::tlc5971 { -class TLC5971 : public Component { +class TLC5971 final : public Component { public: const uint8_t N_CHANNELS_PER_CHIP = 12; diff --git a/esphome/components/tm1621/tm1621.h b/esphome/components/tm1621/tm1621.h index 7708ee6c98..806e69f993 100644 --- a/esphome/components/tm1621/tm1621.h +++ b/esphome/components/tm1621/tm1621.h @@ -11,7 +11,7 @@ class TM1621Display; using tm1621_writer_t = display::DisplayWriter; -class TM1621Display : public PollingComponent { +class TM1621Display final : public PollingComponent { public: void set_writer(tm1621_writer_t &&writer) { this->writer_ = writer; } diff --git a/esphome/components/tm1637/tm1637.h b/esphome/components/tm1637/tm1637.h index 1ad56ae75a..a3dd50fb37 100644 --- a/esphome/components/tm1637/tm1637.h +++ b/esphome/components/tm1637/tm1637.h @@ -21,7 +21,7 @@ class TM1637Key; using tm1637_writer_t = display::DisplayWriter; -class TM1637Display : public PollingComponent { +class TM1637Display final : public PollingComponent { public: void set_writer(tm1637_writer_t &&writer) { this->writer_ = writer; } @@ -92,7 +92,7 @@ class TM1637Display : public PollingComponent { }; #ifdef USE_BINARY_SENSOR -class TM1637Key : public binary_sensor::BinarySensor { +class TM1637Key final : public binary_sensor::BinarySensor { friend class TM1637Display; public: diff --git a/esphome/components/tm1638/binary_sensor/tm1638_key.h b/esphome/components/tm1638/binary_sensor/tm1638_key.h index fba1e43bde..1e6336a1f4 100644 --- a/esphome/components/tm1638/binary_sensor/tm1638_key.h +++ b/esphome/components/tm1638/binary_sensor/tm1638_key.h @@ -5,7 +5,7 @@ namespace esphome::tm1638 { -class TM1638Key : public binary_sensor::BinarySensor, public KeyListener { +class TM1638Key final : public binary_sensor::BinarySensor, public KeyListener { public: void set_keycode(uint8_t key_code) { key_code_ = key_code; }; void keys_update(uint8_t keys) override; diff --git a/esphome/components/tm1638/output/tm1638_output_led.h b/esphome/components/tm1638/output/tm1638_output_led.h index b1c1090447..e0bf5d31d9 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.h +++ b/esphome/components/tm1638/output/tm1638_output_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638OutputLed : public output::BinaryOutput, public Component { +class TM1638OutputLed final : public output::BinaryOutput, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.h b/esphome/components/tm1638/switch/tm1638_switch_led.h index c7154eefb3..8df4678d62 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.h +++ b/esphome/components/tm1638/switch/tm1638_switch_led.h @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -class TM1638SwitchLed : public switch_::Switch, public Component { +class TM1638SwitchLed final : public switch_::Switch, public Component { public: void dump_config() override; diff --git a/esphome/components/tm1638/tm1638.h b/esphome/components/tm1638/tm1638.h index 24d49f4a9f..9ebea05089 100644 --- a/esphome/components/tm1638/tm1638.h +++ b/esphome/components/tm1638/tm1638.h @@ -20,7 +20,7 @@ class TM1638Component; using tm1638_writer_t = display::DisplayWriter; -class TM1638Component : public PollingComponent { +class TM1638Component final : public PollingComponent { public: void set_writer(tm1638_writer_t &&writer) { this->writer_ = writer; } void setup() override; diff --git a/esphome/components/tm1651/tm1651.h b/esphome/components/tm1651/tm1651.h index f1abbcc792..2021f90266 100644 --- a/esphome/components/tm1651/tm1651.h +++ b/esphome/components/tm1651/tm1651.h @@ -12,7 +12,7 @@ enum TM1651Brightness : uint8_t { TM1651_BRIGHTEST = 3, }; -class TM1651Display : public Component { +class TM1651Display final : public Component { public: void set_clk_pin(InternalGPIOPin *pin) { clk_pin_ = pin; } void set_dio_pin(InternalGPIOPin *pin) { dio_pin_ = pin; } @@ -56,7 +56,7 @@ class TM1651Display : public Component { uint8_t level_{0}; }; -template class SetBrightnessAction : public Action, public Parented { +template class SetBrightnessAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, brightness) @@ -66,7 +66,7 @@ template class SetBrightnessAction : public Action, publi } }; -template class SetLevelAction : public Action, public Parented { +template class SetLevelAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level) @@ -76,7 +76,7 @@ template class SetLevelAction : public Action, public Par } }; -template class SetLevelPercentAction : public Action, public Parented { +template class SetLevelPercentAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(uint8_t, level_percent) @@ -86,12 +86,12 @@ template class SetLevelPercentAction : public Action, pub } }; -template class TurnOnAction : public Action, public Parented { +template class TurnOnAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_on(); } }; -template class TurnOffAction : public Action, public Parented { +template class TurnOffAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->turn_off(); } }; diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index aedfefd052..f9eda32e98 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -6,7 +6,7 @@ namespace esphome::tmp102 { -class TMP102Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void dump_config() override; void update() override; diff --git a/esphome/components/tmp1075/tmp1075.h b/esphome/components/tmp1075/tmp1075.h index 4dc9449597..519d48ad29 100644 --- a/esphome/components/tmp1075/tmp1075.h +++ b/esphome/components/tmp1075/tmp1075.h @@ -52,7 +52,7 @@ enum EAlertFunction { ALERT_INTERRUPT = 1, }; -class TMP1075Sensor : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class TMP1075Sensor final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: void setup() override; void update() override; diff --git a/esphome/components/tmp117/tmp117.h b/esphome/components/tmp117/tmp117.h index a8fe7ac7ce..a42a14ca73 100644 --- a/esphome/components/tmp117/tmp117.h +++ b/esphome/components/tmp117/tmp117.h @@ -6,7 +6,7 @@ namespace esphome::tmp117 { -class TMP117Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { +class TMP117Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tof10120/tof10120_sensor.h b/esphome/components/tof10120/tof10120_sensor.h index 8bf92b50a0..932b89ce49 100644 --- a/esphome/components/tof10120/tof10120_sensor.h +++ b/esphome/components/tof10120/tof10120_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tof10120 { -class TOF10120Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TOF10120Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 2a83213ffe..bde5525d10 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -9,7 +9,7 @@ namespace esphome::tormatic { using namespace esphome::cover; -class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingComponent { +class Tormatic final : public cover::Cover, public uart::UARTDevice, public PollingComponent { public: void setup() override; void loop() override; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 1b37c6897d..19950bbd15 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -593,7 +593,7 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { message[3] = ~message[2]; // Byte 4u: Temp if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - temperature = (temperature * 1.8) + 32; + temperature = (temperature * 1.8f) + 32; temp_adjd = temperature - TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN; } diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index 4525d6bffe..a853730f31 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -23,7 +23,7 @@ const float TOSHIBA_RAC_PT1411HWRU_TEMP_F_MAX = 86.0; const float TOSHIBA_RAS_2819T_TEMP_C_MIN = 18.0; const float TOSHIBA_RAS_2819T_TEMP_C_MAX = 30.0; -class ToshibaClimate : public climate_ir::ClimateIR { +class ToshibaClimate final : public climate_ir::ClimateIR { public: ToshibaClimate() : climate_ir::ClimateIR(TOSHIBA_GENERIC_TEMP_C_MIN, TOSHIBA_GENERIC_TEMP_C_MAX, 1.0f, true, true, diff --git a/esphome/components/total_daily_energy/total_daily_energy.h b/esphome/components/total_daily_energy/total_daily_energy.h index 9a20ecea01..a683d43d0f 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.h +++ b/esphome/components/total_daily_energy/total_daily_energy.h @@ -14,7 +14,7 @@ enum TotalDailyEnergyMethod { TOTAL_DAILY_ENERGY_METHOD_RIGHT, }; -class TotalDailyEnergy : public sensor::Sensor, public Component { +class TotalDailyEnergy final : public sensor::Sensor, public Component { public: void set_restore(bool restore) { restore_ = restore; } void set_time(time::RealTimeClock *time) { time_ = time; } diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index 4a5c03ace4..cf0c5fca19 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -60,40 +60,79 @@ def validate_calibration(calibration_config): return calibration_config -CALIBRATION_SCHEMA = cv.All( - cv.Schema( - { - cv.Required(CONF_X_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_X_MAX): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MIN): cv.int_range(min=0, max=4095), - cv.Required(CONF_Y_MAX): cv.int_range(min=0, max=4095), - } - ), - validate_calibration, -) +def option_with_default(option: str, defaults: dict, required: bool = False): + if option in defaults or not required: + return cv.Optional(option, default=defaults.get(option, cv.UNDEFINED)) + return cv.Required(option) -def touchscreen_schema(default_touch_timeout=cv.UNDEFINED, calibration_required=False): - calibration = ( - cv.Required(CONF_CALIBRATION) - if calibration_required - else cv.Optional(CONF_CALIBRATION) - ) +_CALIBRATION_KEYS = {CONF_X_MIN, CONF_X_MAX, CONF_Y_MIN, CONF_Y_MAX} +_TRANSFORM_KEYS = {CONF_SWAP_XY, CONF_MIRROR_X, CONF_MIRROR_Y} + + +def _calibration_schema(defaults: dict, required: bool) -> dict: + """ + Generate Calibration schema. If defaults are provided for all suboptions, + the entire calibration config is optional with a populated default value. + Otherwise, it's optional or required as specified. + """ + if _CALIBRATION_KEYS.issubset(defaults): + key = cv.Optional( + CONF_CALIBRATION, + default={k: v for k, v in defaults.items() if k in _CALIBRATION_KEYS}, + ) + elif required: + key = cv.Required(CONF_CALIBRATION) + else: + key = cv.Optional(CONF_CALIBRATION) + return { + key: cv.All( + cv.Schema( + { + option_with_default(x, defaults, True): cv.int_range( + min=0, max=4095 + ) + for x in _CALIBRATION_KEYS + } + ), + validate_calibration, + ) + } + + +def _transform_schema(defaults: dict) -> dict: + if _TRANSFORM_KEYS.issubset(defaults): + key = cv.Optional( + CONF_TRANSFORM, + default={k: v for k, v in defaults.items() if k in _TRANSFORM_KEYS}, + ) + else: + key = cv.Optional(CONF_TRANSFORM) + return { + key: cv.Schema( + { + cv.Optional(x, default=defaults.get(x, False)): cv.boolean + for x in _TRANSFORM_KEYS + } + ) + } + + +def touchscreen_schema( + default_touch_timeout=cv.UNDEFINED, + calibration_required=False, + defaults: dict = None, +) -> cv.Schema: + defaults = defaults or {} return cv.Schema( { cv.GenerateID(CONF_DISPLAY): cv.use_id(display.Display), - cv.Optional(CONF_TRANSFORM): cv.Schema( - { - cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, - cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, - } - ), cv.Optional(CONF_TOUCH_TIMEOUT, default=default_touch_timeout): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), ), - calibration: CALIBRATION_SCHEMA, + **_transform_schema(defaults), + **_calibration_schema(defaults, calibration_required), cv.Optional(CONF_ON_TOUCH): automation.validate_automation(single=True), cv.Optional(CONF_ON_UPDATE): automation.validate_automation(single=True), cv.Optional(CONF_ON_RELEASE): automation.validate_automation(single=True), diff --git a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h index 2f86bc9749..f95c7a82b1 100644 --- a/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +++ b/esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h @@ -10,10 +10,10 @@ namespace esphome::touchscreen { -class TouchscreenBinarySensor : public binary_sensor::BinarySensor, - public Component, - public TouchListener, - public Parented { +class TouchscreenBinarySensor final : public binary_sensor::BinarySensor, + public Component, + public TouchListener, + public Parented { public: void setup() override; diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 0fbb59c648..8997d19f53 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -26,7 +26,7 @@ enum TSL2561Gain { }; /// This class includes support for the TSL2561 i2c ambient light sensor. -class TSL2561Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: /** Set the time that sensor values should be accumulated for. * diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 4b63c8ec40..3fde340412 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -63,7 +63,7 @@ enum TSL2591SensorChannel { /// light. They are reported as separate sensors, and the difference /// between the values is reported as a third sensor as a convenience /// for visible light only. -class TSL2591Component : public PollingComponent, public i2c::I2CDevice { +class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { public: /** Set device integration time and gain. * diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.h b/esphome/components/tt21100/binary_sensor/tt21100_button.h index a1f5946447..f4073caf3d 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.h +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.h @@ -7,10 +7,10 @@ namespace esphome::tt21100 { -class TT21100Button : public binary_sensor::BinarySensor, - public Component, - public TT21100ButtonListener, - public Parented { +class TT21100Button final : public binary_sensor::BinarySensor, + public Component, + public TT21100ButtonListener, + public Parented { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tt21100/touchscreen/tt21100.h b/esphome/components/tt21100/touchscreen/tt21100.h index 3c6030c9c1..31af9085b5 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.h +++ b/esphome/components/tt21100/touchscreen/tt21100.h @@ -16,7 +16,7 @@ class TT21100ButtonListener { virtual void update_button(uint8_t index, uint16_t state) = 0; }; -class TT21100Touchscreen : public Touchscreen, public i2c::I2CDevice { +class TT21100Touchscreen final : public Touchscreen, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.h b/esphome/components/ttp229_bsf/ttp229_bsf.h index 07f0c638c2..109764e51d 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.h +++ b/esphome/components/ttp229_bsf/ttp229_bsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_bsf { -class TTP229BSFChannel : public binary_sensor::BinarySensor { +class TTP229BSFChannel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229BSFChannel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229BSFComponent : public Component { +class TTP229BSFComponent final : public Component { public: void set_sdo_pin(GPIOPin *sdo_pin) { sdo_pin_ = sdo_pin; } void set_scl_pin(GPIOPin *scl_pin) { scl_pin_ = scl_pin; } diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.h b/esphome/components/ttp229_lsf/ttp229_lsf.h index 09e7745d25..50e2baa7f7 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.h +++ b/esphome/components/ttp229_lsf/ttp229_lsf.h @@ -8,7 +8,7 @@ namespace esphome::ttp229_lsf { -class TTP229Channel : public binary_sensor::BinarySensor { +class TTP229Channel final : public binary_sensor::BinarySensor { public: void set_channel(uint8_t channel) { channel_ = channel; } void process(uint16_t data) { this->publish_state(data & (1 << this->channel_)); } @@ -17,7 +17,7 @@ class TTP229Channel : public binary_sensor::BinarySensor { uint8_t channel_; }; -class TTP229LSFComponent : public Component, public i2c::I2CDevice { +class TTP229LSFComponent final : public Component, public i2c::I2CDevice { public: void register_channel(TTP229Channel *channel) { this->channels_.push_back(channel); } void setup() override; diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h index f5c806b013..0cd63a76be 100644 --- a/esphome/components/tuya/automation.h +++ b/esphome/components/tuya/automation.h @@ -8,44 +8,44 @@ namespace esphome::tuya { -class TuyaDatapointUpdateTrigger : public Trigger { +class TuyaDatapointUpdateTrigger final : public Trigger { public: explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); } }; -class TuyaRawDatapointUpdateTrigger : public Trigger> { +class TuyaRawDatapointUpdateTrigger final : public Trigger> { public: explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBoolDatapointUpdateTrigger : public Trigger { +class TuyaBoolDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaIntDatapointUpdateTrigger : public Trigger { +class TuyaIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaUIntDatapointUpdateTrigger : public Trigger { +class TuyaUIntDatapointUpdateTrigger final : public Trigger { public: explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaStringDatapointUpdateTrigger : public Trigger { +class TuyaStringDatapointUpdateTrigger final : public Trigger { public: explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaEnumDatapointUpdateTrigger : public Trigger { +class TuyaEnumDatapointUpdateTrigger final : public Trigger { public: explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; -class TuyaBitmaskDatapointUpdateTrigger : public Trigger { +class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { public: explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); }; diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h index f92652d087..76d7da4604 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaBinarySensor : public binary_sensor::BinarySensor, public Component { +class TuyaBinarySensor final : public binary_sensor::BinarySensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/climate/tuya_climate.h b/esphome/components/tuya/climate/tuya_climate.h index b9fb45257a..015da1930c 100644 --- a/esphome/components/tuya/climate/tuya_climate.h +++ b/esphome/components/tuya/climate/tuya_climate.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaClimate : public climate::Climate, public Component { +class TuyaClimate final : public climate::Climate, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/tuya/cover/tuya_cover.h b/esphome/components/tuya/cover/tuya_cover.h index ab63975683..fb38c81377 100644 --- a/esphome/components/tuya/cover/tuya_cover.h +++ b/esphome/components/tuya/cover/tuya_cover.h @@ -12,7 +12,7 @@ enum TuyaCoverRestoreMode { COVER_RESTORE_AND_CALL, }; -class TuyaCover : public cover::Cover, public Component { +class TuyaCover final : public cover::Cover, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/fan/tuya_fan.h b/esphome/components/tuya/fan/tuya_fan.h index bfb6bdeca0..70b127c10e 100644 --- a/esphome/components/tuya/fan/tuya_fan.h +++ b/esphome/components/tuya/fan/tuya_fan.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaFan : public Component, public fan::Fan { +class TuyaFan final : public Component, public fan::Fan { public: TuyaFan(Tuya *parent, int speed_count) : parent_(parent), speed_count_(speed_count) {} void setup() override; diff --git a/esphome/components/tuya/light/tuya_light.h b/esphome/components/tuya/light/tuya_light.h index d990eea72a..c921efc145 100644 --- a/esphome/components/tuya/light/tuya_light.h +++ b/esphome/components/tuya/light/tuya_light.h @@ -8,7 +8,7 @@ namespace esphome::tuya { enum TuyaColorType { RGB, HSV, RGBHSV }; -class TuyaLight : public Component, public light::LightOutput { +class TuyaLight final : public Component, public light::LightOutput { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/number/tuya_number.h b/esphome/components/tuya/number/tuya_number.h index 51c53a4442..a7289bb803 100644 --- a/esphome/components/tuya/number/tuya_number.h +++ b/esphome/components/tuya/number/tuya_number.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaNumber : public number::Number, public Component { +class TuyaNumber final : public number::Number, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/select/tuya_select.h b/esphome/components/tuya/select/tuya_select.h index f8d2d89ea8..4da01411b7 100644 --- a/esphome/components/tuya/select/tuya_select.h +++ b/esphome/components/tuya/select/tuya_select.h @@ -8,7 +8,7 @@ namespace esphome::tuya { -class TuyaSelect : public select::Select, public Component { +class TuyaSelect final : public select::Select, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/sensor/tuya_sensor.h b/esphome/components/tuya/sensor/tuya_sensor.h index b700fc8bd7..65f9dc599a 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.h +++ b/esphome/components/tuya/sensor/tuya_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSensor : public sensor::Sensor, public Component { +class TuyaSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/switch/tuya_switch.h b/esphome/components/tuya/switch/tuya_switch.h index 7e0109c34c..4cd137a6c8 100644 --- a/esphome/components/tuya/switch/tuya_switch.h +++ b/esphome/components/tuya/switch/tuya_switch.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaSwitch : public switch_::Switch, public Component { +class TuyaSwitch final : public switch_::Switch, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.h b/esphome/components/tuya/text_sensor/tuya_text_sensor.h index c9ac64deb8..2969bbf74b 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.h +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.h @@ -6,7 +6,7 @@ namespace esphome::tuya { -class TuyaTextSensor : public text_sensor::TextSensor, public Component { +class TuyaTextSensor final : public text_sensor::TextSensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index 470b97e7e7..4e7ab5c7f9 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -84,7 +84,7 @@ struct TuyaCommand { std::vector payload; }; -class Tuya : public Component, public uart::UARTDevice { +class Tuya final : public Component, public uart::UARTDevice { public: float get_setup_priority() const override { return setup_priority::LATE; } void setup() override; diff --git a/esphome/components/tx20/tx20.h b/esphome/components/tx20/tx20.h index 7ca29eaf3b..e4dc7dfab0 100644 --- a/esphome/components/tx20/tx20.h +++ b/esphome/components/tx20/tx20.h @@ -21,7 +21,7 @@ struct Tx20ComponentStore { }; /// This class implements support for the Tx20 Wind sensor. -class Tx20Component : public Component { +class Tx20Component final : public Component { public: /// Get the textual representation of the wind direction ('N', 'SSE', ..). std::string get_wind_cardinal_direction() const; diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 4ea32e26a3..7e3701bb07 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -49,7 +49,7 @@ IDFUARTComponent = uart_ns.class_("IDFUARTComponent", UARTComponent, cg.Componen ESP8266UartComponent = uart_ns.class_( "ESP8266UartComponent", UARTComponent, cg.Component ) -RP2040UartComponent = uart_ns.class_("RP2040UartComponent", UARTComponent, cg.Component) +RP2UartComponent = uart_ns.class_("RP2UartComponent", UARTComponent, cg.Component) LibreTinyUARTComponent = uart_ns.class_( "LibreTinyUARTComponent", UARTComponent, cg.Component ) @@ -59,7 +59,7 @@ HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Compon NATIVE_UART_CLASSES = ( str(IDFUARTComponent), str(ESP8266UartComponent), - str(RP2040UartComponent), + str(RP2UartComponent), str(LibreTinyUARTComponent), ) @@ -157,8 +157,8 @@ def _uart_declare_type(value): return cv.declare_id(ESP8266UartComponent)(value) if CORE.is_esp32: return cv.declare_id(IDFUARTComponent)(value) - if CORE.is_rp2040: - return cv.declare_id(RP2040UartComponent)(value) + if CORE.is_rp2: + return cv.declare_id(RP2UartComponent)(value) if CORE.is_libretiny: return cv.declare_id(LibreTinyUARTComponent)(value) if CORE.is_host: @@ -529,7 +529,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, "uart_component_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "uart_component_host.cpp": {PlatformFramework.HOST_NATIVE}, - "uart_component_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "uart_component_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "uart_component_libretiny.cpp": { PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index c99caac97b..e5a9fa7c7b 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -7,7 +7,7 @@ namespace esphome::uart { -template class UARTWriteAction : public Action, public Parented { +template class UARTWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 2b530d3c4b..47f45d4899 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class UARTButton : public button::Button, public UARTDevice, public Component { +class UARTButton final : public button::Button, public UARTDevice, public Component { public: void set_data(std::vector &&data) { this->data_ = std::move(data); } void set_data(std::initializer_list data) { this->data_ = std::vector(data); } diff --git a/esphome/components/uart/event/uart_event.h b/esphome/components/uart/event/uart_event.h index 8a00b5894b..3960ffd5bb 100644 --- a/esphome/components/uart/event/uart_event.h +++ b/esphome/components/uart/event/uart_event.h @@ -7,7 +7,7 @@ namespace esphome::uart { -class UARTEvent : public event::Event, public UARTDevice, public Component { +class UARTEvent final : public event::Event, public UARTDevice, public Component { public: void setup() override; void loop() override; diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index 1c92af536e..b1ce8ac590 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -20,7 +20,7 @@ static const uint16_t MAX_PACKET_SIZE = 508; static const uint8_t FLAG_BYTE = 0x7E; static const uint8_t CONTROL_BYTE = 0x7D; -class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { +class UARTTransport final : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 5730fc9b4b..c924c7d4e5 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -9,7 +9,7 @@ namespace esphome::uart { -class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { +class UARTSwitch final : public switch_::Switch, public UARTDevice, public Component { public: void loop() override; diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index afd3ad5777..3e52531791 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -178,7 +178,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(bool dump_config){}; + virtual void load_settings(bool dump_config) = 0; /** * Load the UART settings. @@ -190,7 +190,7 @@ class UARTComponent { * * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ - virtual void load_settings(){}; + void load_settings() { this->load_settings(true); } #endif // USE_ESP8266 || USE_ESP32 #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 7f844d9b65..469885b6b6 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -46,7 +46,7 @@ class ESP8266SoftwareSerial { ISRInternalGPIOPin rx_pin_; }; -class ESP8266UartComponent : public UARTComponent, public Component { +class ESP8266UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; @@ -75,7 +75,7 @@ class ESP8266UartComponent : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index ec4f2884b2..649dd3aa46 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -16,7 +16,7 @@ namespace esphome::uart { /// Thread safety: All public methods must only be called from the main loop. /// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's /// peek byte state (has_peek_/peek_byte_) is not synchronized. -class IDFUARTComponent : public UARTComponent, public Component { +class IDFUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; @@ -50,7 +50,7 @@ class IDFUARTComponent : public UARTComponent, public Component { * This will load the current UART interface with the latest settings (baud_rate, parity, etc). */ void load_settings(bool dump_config) override; - void load_settings() override { this->load_settings(true); } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience protected: void check_logger_conflict() override; diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index a47e5649be..bca62debf1 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -8,7 +8,7 @@ namespace esphome::uart { -class HostUartComponent : public UARTComponent, public Component { +class HostUartComponent final : public UARTComponent, public Component { public: virtual ~HostUartComponent(); void setup() override; diff --git a/esphome/components/uart/uart_component_libretiny.h b/esphome/components/uart/uart_component_libretiny.h index 872ea86601..aa13a01392 100644 --- a/esphome/components/uart/uart_component_libretiny.h +++ b/esphome/components/uart/uart_component_libretiny.h @@ -10,7 +10,7 @@ namespace esphome::uart { -class LibreTinyUARTComponent : public UARTComponent, public Component { +class LibreTinyUARTComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2.cpp similarity index 91% rename from esphome/components/uart/uart_component_rp2040.cpp rename to esphome/components/uart/uart_component_rp2.cpp index 1aaf98dc84..9cc3009a22 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -1,5 +1,5 @@ -#ifdef USE_RP2040 -#include "uart_component_rp2040.h" +#ifdef USE_RP2 +#include "uart_component_rp2.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -13,9 +13,9 @@ namespace esphome::uart { -static const char *const TAG = "uart.arduino_rp2040"; +static const char *const TAG = "uart.arduino_rp2"; -uint16_t RP2040UartComponent::get_config() { +uint16_t RP2UartComponent::get_config() { uint16_t config = 0; if (this->parity_ == UART_CONFIG_PARITY_NONE) { @@ -50,7 +50,7 @@ uint16_t RP2040UartComponent::get_config() { return config; } -void RP2040UartComponent::setup() { +void RP2UartComponent::setup() { auto setup_pin_if_needed = [](InternalGPIOPin *pin) { if (!pin) { return; @@ -162,7 +162,7 @@ void RP2040UartComponent::setup() { } } -void RP2040UartComponent::dump_config() { +void RP2UartComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus:"); LOG_PIN(" TX Pin: ", tx_pin_); LOG_PIN(" RX Pin: ", rx_pin_); @@ -182,7 +182,7 @@ void RP2040UartComponent::dump_config() { } } -void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { +void RP2UartComponent::write_array(const uint8_t *data, size_t len) { this->serial_->write(data, len); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { @@ -190,13 +190,13 @@ void RP2040UartComponent::write_array(const uint8_t *data, size_t len) { } #endif } -bool RP2040UartComponent::peek_byte(uint8_t *data) { +bool RP2UartComponent::peek_byte(uint8_t *data) { if (!this->check_read_timeout_()) return false; *data = this->serial_->peek(); return true; } -bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { +bool RP2UartComponent::read_array(uint8_t *data, size_t len) { if (!this->check_read_timeout_(len)) return false; this->serial_->readBytes(data, len); @@ -207,12 +207,12 @@ bool RP2040UartComponent::read_array(uint8_t *data, size_t len) { #endif return true; } -size_t RP2040UartComponent::available() { return this->serial_->available(); } -UARTFlushResult RP2040UartComponent::flush() { +size_t RP2UartComponent::available() { return this->serial_->available(); } +UARTFlushResult RP2UartComponent::flush() { ESP_LOGVV(TAG, " Flushing"); this->serial_->flush(); return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; } } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2.h similarity index 89% rename from esphome/components/uart/uart_component_rp2040.h rename to esphome/components/uart/uart_component_rp2.h index 198c698af9..734bc6022e 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #include @@ -13,7 +13,7 @@ namespace esphome::uart { -class RP2040UartComponent : public UARTComponent, public Component { +class RP2UartComponent final : public UARTComponent, public Component { public: void setup() override; void dump_config() override; @@ -40,4 +40,4 @@ class RP2040UartComponent : public UARTComponent, public Component { }; } // namespace esphome::uart -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/uart/uart_debugger.h b/esphome/components/uart/uart_debugger.h index da33bea70c..b69dcf0676 100644 --- a/esphome/components/uart/uart_debugger.h +++ b/esphome/components/uart/uart_debugger.h @@ -18,7 +18,7 @@ namespace esphome::uart { /// 'appropriate time' means exactly, is determined by a number of /// configurable constraints. E.g. when a given number of bytes is gathered /// and/or when no more data has been seen for a given time interval. -class UARTDebugger : public Component, public Trigger, StringRef> { +class UARTDebugger final : public Component, public Trigger, StringRef> { public: explicit UARTDebugger(UARTComponent *parent); void loop() override; @@ -73,7 +73,7 @@ class UARTDebugger : public Component, public Trigger class UDPWriteAction : public Action, public Parented { +template class UDPWriteAction final : public Action, public Parented { public: void set_data_template(std::vector (*func)(Ts...)) { this->data_.func = func; diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index 8621ddca48..e91a3e2a5a 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -8,7 +8,7 @@ namespace esphome::udp { -class UDPTransport : public packet_transport::PacketTransport, public Parented { +class UDPTransport final : public packet_transport::PacketTransport, public Parented { public: void setup() override; diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index fb0edf2ebd..274e0119ee 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -18,7 +18,7 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; -class UDPComponent : public Component { +class UDPComponent final : public Component { public: void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } /// Prevent accidental use of std::string which would dangle diff --git a/esphome/components/ufire_ec/ufire_ec.h b/esphome/components/ufire_ec/ufire_ec.h index fce6258632..0928fda9ee 100644 --- a/esphome/components/ufire_ec/ufire_ec.h +++ b/esphome/components/ufire_ec/ufire_ec.h @@ -24,7 +24,7 @@ static const uint8_t COMMAND_CALIBRATE_PROBE = 20; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_EC = 80; -class UFireECComponent : public PollingComponent, public i2c::I2CDevice { +class UFireECComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireECComponent : public PollingComponent, public i2c::I2CDevice { float temperature_coefficient_{0.0}; }; -template class UFireECCalibrateProbeAction : public Action { +template class UFireECCalibrateProbeAction final : public Action { public: UFireECCalibrateProbeAction(UFireECComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -72,7 +72,7 @@ template class UFireECCalibrateProbeAction : public Action class UFireECResetAction : public Action { +template class UFireECResetAction final : public Action { public: UFireECResetAction(UFireECComponent *parent) : parent_(parent) {} diff --git a/esphome/components/ufire_ise/ufire_ise.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index bd2dc2836e..d595b37a83 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -70,19 +70,19 @@ float UFireISEComponent::measure_ph_(float temperature) { if (mv == -1) return -1; - ph = fabs(7.0 - (mv / PROBE_MV_TO_PH)); + ph = fabsf(7.0f - (mv / PROBE_MV_TO_PH)); // Determine the temperature correction float distance_from_7 = std::abs(7 - roundf(ph)); float distance_from_25 = std::floor(std::abs(25 - roundf(temperature)) / 10); float temp_multiplier = (distance_from_25 * distance_from_7) * PROBE_TMP_CORRECTION; - if ((ph >= 8.0) && (temperature >= 35)) + if ((ph >= 8.0f) && (temperature >= 35)) temp_multiplier *= -1; - if ((ph <= 6.0) && (temperature <= 15)) + if ((ph <= 6.0f) && (temperature <= 15)) temp_multiplier *= -1; ph += temp_multiplier; - if ((ph <= 0.0) || (ph > 14.0)) + if ((ph <= 0.0f) || (ph > 14.0f)) ph = -1; if (std::isinf(ph)) ph = -1; diff --git a/esphome/components/ufire_ise/ufire_ise.h b/esphome/components/ufire_ise/ufire_ise.h index bff8eeff9d..85916f227e 100644 --- a/esphome/components/ufire_ise/ufire_ise.h +++ b/esphome/components/ufire_ise/ufire_ise.h @@ -29,7 +29,7 @@ static const uint8_t COMMAND_CALIBRATE_LOW = 10; static const uint8_t COMMAND_MEASURE_TEMP = 40; static const uint8_t COMMAND_MEASURE_MV = 80; -class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { +class UFireISEComponent final : public PollingComponent, public i2c::I2CDevice { public: void setup() override; void update() override; @@ -58,7 +58,7 @@ class UFireISEComponent : public PollingComponent, public i2c::I2CDevice { sensor::Sensor *ph_sensor_{nullptr}; }; -template class UFireISECalibrateProbeLowAction : public Action { +template class UFireISECalibrateProbeLowAction final : public Action { public: UFireISECalibrateProbeLowAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -69,7 +69,7 @@ template class UFireISECalibrateProbeLowAction : public Action class UFireISECalibrateProbeHighAction : public Action { +template class UFireISECalibrateProbeHighAction final : public Action { public: UFireISECalibrateProbeHighAction(UFireISEComponent *parent) : parent_(parent) {} TEMPLATABLE_VALUE(float, solution) @@ -80,7 +80,7 @@ template class UFireISECalibrateProbeHighAction : public Action< UFireISEComponent *parent_; }; -template class UFireISEResetAction : public Action { +template class UFireISEResetAction final : public Action { public: UFireISEResetAction(UFireISEComponent *parent) : parent_(parent) {} 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/uln2003/uln2003.h b/esphome/components/uln2003/uln2003.h index 70f55f72bf..1b1a16f95e 100644 --- a/esphome/components/uln2003/uln2003.h +++ b/esphome/components/uln2003/uln2003.h @@ -12,7 +12,7 @@ enum ULN2003StepMode { ULN2003_STEP_MODE_WAVE_DRIVE, }; -class ULN2003 : public stepper::Stepper, public Component { +class ULN2003 final : public stepper::Stepper, public Component { public: void set_pin_a(GPIOPin *pin_a) { pin_a_ = pin_a; } void set_pin_b(GPIOPin *pin_b) { pin_b_ = pin_b; } diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.h b/esphome/components/ultrasonic/ultrasonic_sensor.h index 7d333a1b24..ea8fcbf72e 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.h +++ b/esphome/components/ultrasonic/ultrasonic_sensor.h @@ -18,7 +18,7 @@ struct UltrasonicSensorStore { volatile bool echo_end{false}; }; -class UltrasonicSensorComponent : public sensor::Sensor, public PollingComponent { +class UltrasonicSensorComponent final : public sensor::Sensor, public PollingComponent { public: void set_trigger_pin(InternalGPIOPin *trigger_pin) { this->trigger_pin_ = trigger_pin; } void set_echo_pin(InternalGPIOPin *echo_pin) { this->echo_pin_ = echo_pin; } diff --git a/esphome/components/update/__init__.py b/esphome/components/update/__init__.py index ddb471be18..18d333a5ef 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -54,7 +54,9 @@ _UPDATE_SCHEMA = ( .extend( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_ON_UPDATE_AVAILABLE): automation.validate_automation( single=True ), @@ -136,7 +138,9 @@ async def to_code(config): automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(UpdateEntity), - cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean), + cv.Optional( + CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED + ): cv.templatable(cv.boolean), } ), synchronous=True, diff --git a/esphome/components/update/automation.h b/esphome/components/update/automation.h index 821151f67c..8ba7b71a9c 100644 --- a/esphome/components/update/automation.h +++ b/esphome/components/update/automation.h @@ -6,19 +6,19 @@ namespace esphome::update { -template class PerformAction : public Action, public Parented { +template class PerformAction final : public Action, public Parented { TEMPLATABLE_VALUE(bool, force) public: void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } }; -template class CheckAction : public Action, public Parented { +template class CheckAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->check(); } }; -template class IsAvailableCondition : public Condition, public Parented { +template class IsAvailableCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } }; diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h index 4cc5a4a3bc..4755655747 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixClimate : public climate::Climate, public Component, public UponorSmatrixDevice { +class UponorSmatrixClimate final : public climate::Climate, public Component, public UponorSmatrixDevice { public: void dump_config() override; void loop() override; diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h index 346fe1e3d6..b507642fce 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h @@ -6,7 +6,7 @@ namespace esphome::uponor_smatrix { -class UponorSmatrixSensor : public sensor::Sensor, public Component, public UponorSmatrixDevice { +class UponorSmatrixSensor final : public sensor::Sensor, public Component, public UponorSmatrixDevice { SUB_SENSOR(temperature) SUB_SENSOR(external_temperature) SUB_SENSOR(humidity) diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.h b/esphome/components/uponor_smatrix/uponor_smatrix.h index e9e772feab..8476c6bac2 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.h +++ b/esphome/components/uponor_smatrix/uponor_smatrix.h @@ -62,7 +62,7 @@ struct UponorSmatrixData { class UponorSmatrixDevice; -class UponorSmatrixComponent : public uart::UARTDevice, public Component { +class UponorSmatrixComponent final : public uart::UARTDevice, public Component { public: UponorSmatrixComponent() = default; diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index 1b80a4480a..b0b12954b2 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -5,7 +5,7 @@ namespace esphome::uptime { -class UptimeSecondsSensor : public sensor::Sensor, public PollingComponent { +class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: void update() override; void dump_config() override; diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h index 912c0b7655..5b837cbce1 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.h +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.h @@ -10,7 +10,7 @@ namespace esphome::uptime { -class UptimeTimestampSensor : public sensor::Sensor, public Component { +class UptimeTimestampSensor final : public sensor::Sensor, public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index a97ba332bb..0bdc7fe404 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -7,7 +7,7 @@ namespace esphome::uptime { -class UptimeTextSensor : public text_sensor::TextSensor, public PollingComponent { +class UptimeTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: UptimeTextSensor(const char *days_text, const char *hours_text, const char *minutes_text, const char *seconds_text, const char *separator, bool expand) diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index bfe177a4da..8cd078ab49 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -1,9 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32, uart from esphome.components.esp32 import ( + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, ) import esphome.config_validation as cv @@ -48,7 +50,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( - supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], ), ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 40f7f2e28b..454c049da3 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_cdc_acm.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index 89405ab893..8e71fc61b2 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -1,5 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/event_pool.h" @@ -50,7 +51,7 @@ struct CDCEvent { class USBCDCACMComponent; /// Represents a single CDC ACM interface instance -class USBCDCACMInstance : public uart::UARTComponent, public Parented { +class USBCDCACMInstance final : public uart::UARTComponent, public Parented { public: void setup(); void loop(); @@ -83,6 +84,12 @@ class USBCDCACMInstance : public uart::UARTComponent, public Parented None: # IDF 6.0 moved USB host to an external component if idf_version() >= cv.Version(6, 0, 0): - add_idf_component(name="espressif/usb", ref="1.3.0") + add_idf_component(name="espressif/usb", ref="1.4.1") add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024) if config.get(CONF_ENABLE_HUBS): add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index a9f07a5422..42869fb2a6 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,8 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" #include @@ -183,7 +184,7 @@ class USBClient : public Component { uint16_t vid_{}; uint16_t pid_{}; }; -class USBHost : public Component { +class USBHost final : public Component { public: float get_setup_priority() const override { return setup_priority::BUS; } void loop() override; @@ -195,4 +196,5 @@ class USBHost : public Component { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 45e2be17c7..7bc2b0a16b 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -581,4 +582,5 @@ void USBClient::release_trq(TransferRequest *trq) { } } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 8ce0a70dc9..102311348a 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,6 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_host.h" #include #include "esphome/core/log.h" @@ -29,4 +30,5 @@ void USBHost::loop() { } // namespace esphome::usb_host -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index e42a2c092b..a921b6fbf0 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -46,42 +46,59 @@ DEFAULT_BAUD_RATE = 9600 class Type: - def __init__(self, name, vid, pid, cls, max_channels=1, baud_rate_required=True): + def __init__( + self, + name, + vid, + pid, + cls, + max_channels=1, + baud_rate_required=True, + max_baud=1_000_000, + ): self.name = name cls = cls or name self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) - self.max_channels = max_channels + self._max_channels = max_channels self.baud_rate_required = baud_rate_required + self.max_baud = max_baud + + @property + def max_channels(self) -> int: + return ( + 3 + if ( + CORE.is_esp32 + and get_esp32_variant() != VARIANT_ESP32P4 + and self._max_channels > 3 + ) + else self._max_channels + ) uart_types = ( Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), - Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4), - Type("CH340", 0x1A86, 0x7523, "CH34X", 1), - Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), - Type("FT232", 0x0403, 0x6001, "FT23XX", 1), - Type("FT2232", 0x0403, 0x6010, "FT23XX", 2), - Type("FT4232", 0x0403, 0x6011, "FT23XX", 4), - Type("PL2303", 0x067B, 0x2303, "PL2303", 1), - Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1), - Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1), - Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1), - Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1), - Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1), - Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1), + Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), + Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), + Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), + Type("PL2303", 0x067B, 0x2303, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GB", 0x067B, 0x23B3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GC", 0x067B, 0x23A3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GE", 0x067B, 0x23E3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), + Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), ) -def channel_schema(channels, baud_rate_required): - # For now S3 is restricted to 3 channels since each needs 2 endpoints, plus the control endpoint, and - # there are only a total of 8 endpoints available. - # This will need updating when the 8 channel devices that multiplex over an endpoint are added. - if CORE.is_esp32 and get_esp32_variant() != VARIANT_ESP32P4 and channels > 3: - channels = 3 +def channel_schema(type_: "Type") -> cv.Schema: return cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( @@ -94,11 +111,11 @@ def channel_schema(channels, baud_rate_required): ), ( cv.Required(CONF_BAUD_RATE) - if baud_rate_required + if type_.baud_rate_required else cv.Optional( CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE ) - ): cv.int_range(min=300, max=1000000), + ): cv.int_range(min=300, max=type_.max_baud), cv.Optional(CONF_STOP_BITS, default="1"): cv.enum( UART_STOP_BITS_OPTIONS, upper=True ), @@ -117,7 +134,10 @@ def channel_schema(channels, baud_rate_required): } ) ), - cv.Length(max=channels), + cv.Length( + max=type_.max_channels, + msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", + ), ) } ) @@ -127,7 +147,7 @@ CONFIG_SCHEMA = cv.ensure_list( cv.typed_schema( { it.name: usb_device_schema(it.cls, it.vid, it.pid).extend( - channel_schema(it.max_channels, it.baud_rate_required) + channel_schema(it) ) for it in uart_types }, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index c5f904ead1..abfed74f94 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -49,47 +50,44 @@ static const CH34xEntry CH34X_TABLE[] = { {"CH346C_M2", 0x55EC, 1, 0xFF, 0xFF, CHIP_CH346C_M2, 2}, }; -void USBUartTypeCH34X::enable_channels() { - usb_host::transfer_cb_t cb = [this](const usb_host::TransferStatus &status) { - if (!status.success) { - this->defer([this, error_code = status.error_code]() { - ESP_LOGE(TAG, "CH34x chip detection failed: %s", esp_err_to_name(error_code)); - this->apply_line_settings_(); - }); - return; - } - CH34xChipType chiptype = CHIP_UNKNOWN; - uint8_t num_ports = 1; - for (const auto &e : CH34X_TABLE) { - if (e.pid != this->pid_) - continue; - if (e.match != 0xFF && (status.data[e.byte_idx] & e.mask) != e.match) - continue; - chiptype = e.chiptype; - num_ports = e.num_ports; +bool USBUartTypeCH34X::config_device_step(uint8_t step, bool ok, const uint8_t *response) { + if (step == 0) { + // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes + // used to distinguish CH34x variants sharing the same PID. + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}); + return true; + } + // step 1: parse the chip-version response (falling back to "unknown" on failure). + if (!ok) { + ESP_LOGE(TAG, "CH34x chip detection failed"); + return false; + } + CH34xChipType chiptype = CHIP_UNKNOWN; + uint8_t num_ports = 1; + for (const auto &e : CH34X_TABLE) { + if (e.pid != this->pid_) + continue; + if (e.match != 0xFF && (response[e.byte_idx] & e.mask) != e.match) + continue; + chiptype = e.chiptype; + num_ports = e.num_ports; + break; + } + // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) + if (chiptype == CHIP_CH344L && (response[0] & 0xF0) != 0x40) + chiptype = CHIP_CH344L_V2; + const char *name = "unknown"; + for (const auto &e : CH34X_TABLE) { + if (e.chiptype == chiptype) { + name = e.name; break; } - // CH344L vs CH344L_V2 requires chipver (data[0]) in addition to chiptype (data[1]) - if (chiptype == CHIP_CH344L && (status.data[0] & 0xF0) != 0x40) - chiptype = CHIP_CH344L_V2; - const char *name = "unknown"; - for (const auto &e : CH34X_TABLE) { - if (e.chiptype == chiptype) { - name = e.name; - break; - } - } - this->defer([this, chiptype, num_ports, name]() { - this->chiptype_ = chiptype; - this->chip_name_ = name; - this->num_ports_ = num_ports; - ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); - this->apply_line_settings_(); - }); - }; - // Vendor-specific GET_CHIP_VERSION request (bRequest=0x5F): returns chip ID bytes - // used to distinguish CH34x variants sharing the same PID. - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_IN, 0x5F, 0, 0, cb, {0, 0, 0, 0, 0, 0, 0, 0}); + } + this->chiptype_ = chiptype; + this->chip_name_ = name; + this->num_ports_ = num_ports; + ESP_LOGD(TAG, "CH34x chip: %s, ports: %u", name, this->num_ports_); + return false; } void USBUartTypeCH34X::dump_config() { @@ -97,67 +95,64 @@ void USBUartTypeCH34X::dump_config() { ESP_LOGCONFIG(TAG, " CH34x chip: %s", this->chip_name_); } -void USBUartTypeCH34X::apply_line_settings_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); +bool USBUartTypeCH34X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + uint8_t cmd = 0xA1 + channel->index_; + if (channel->index_ >= 2) + cmd += 0xE; + switch (step) { + case 0: { + uint8_t divisor = 7; + uint32_t clk = 12000000; + + auto baud_rate = channel->baud_rate_; + if (baud_rate < 256000) { + if (baud_rate > 6000000 / 255) { + divisor = 3; + clk = 6000000; + } else if (baud_rate > 750000 / 255) { + divisor = 2; + clk = 750000; + } else if (baud_rate > 93750 / 255) { + divisor = 1; + clk = 93750; + } else { + divisor = 0; + clk = 11719; + } } - }; - - uint8_t divisor = 7; - uint32_t clk = 12000000; - - auto baud_rate = channel->baud_rate_; - if (baud_rate < 256000) { - if (baud_rate > 6000000 / 255) { - divisor = 3; - clk = 6000000; - } else if (baud_rate > 750000 / 255) { - divisor = 2; - clk = 750000; - } else if (baud_rate > 93750 / 255) { - divisor = 1; - clk = 93750; - } else { - divisor = 0; - clk = 11719; + ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); + auto factor = static_cast(clk / baud_rate); + if (factor == 0 || factor == 0xFF) { + ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); + return false; } - } - ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); - auto factor = static_cast(clk / baud_rate); - if (factor == 0 || factor == 0xFF) { - ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); - channel->initialised_.store(false); - continue; - } - if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) - factor++; - factor = 256 - factor; + if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) + factor++; + factor = 256 - factor; - uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) - value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; + uint16_t value = 0xC0; + if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + value |= 4; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + break; + default: + value |= 8 | ((channel->parity_ - 1) << 4); + break; + } + value |= channel->data_bits_ - 5; + value <<= 8; + value |= 0x8C; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor); + return true; } - value |= channel->data_bits_ - 5; - value <<= 8; - value |= 0x8C; - uint8_t cmd = 0xA1 + channel->index_; - if (channel->index_ >= 2) - cmd += 0xE; - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); - this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0, callback); + case 1: + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd + 3, 0x80, 0); + return true; + default: + return false; } - this->start_channels_(); } std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { @@ -170,4 +165,5 @@ std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_ } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 67fd03a813..2722ec8555 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,5 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -96,30 +97,33 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypeCP210X::enable_channels() { - // enable the channels - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } - }; - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; - line_control |= channel->data_bits_ << 8; - ESP_LOGD(TAG, "Line control value 0x%X", line_control); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_, - callback); - auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); - this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, - baud.get_data()); +bool USBUartTypeCP210X::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload, skip the one-time IFC_ENABLE step (the interface is already enabled). + if (reload) + step++; + switch (step) { + case 0: + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); + return true; + case 1: { + uint16_t line_control = channel->stop_bits_; + line_control |= static_cast(channel->parity_) << 4; + line_control |= channel->data_bits_ << 8; + ESP_LOGD(TAG, "Line control value 0x%X", line_control); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); + return true; + } + case 2: { + auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); + this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data()); + return true; + } + default: + return false; } - this->start_channels_(); } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index c2c8993805..79aa107d72 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -1,10 +1,13 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_debugger.h" #include "esphome/components/bytebuffer/bytebuffer.h" +#include namespace esphome::usb_uart { @@ -109,40 +112,46 @@ static int ftdi_to_clkbits(int baudrate, unsigned int clk, int clk_div, uint32_t return best_baud; } -static int ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index, uint16_t *value, - uint16_t *index) { +struct FtdiConfig { + uint16_t value; + uint16_t ftdi_index; int best_baud; +}; + +static FtdiConfig ftdi_convert_baudrate(int baudrate, uint8_t chip_type, uint8_t channel_index) { uint32_t encoded_divisor; + FtdiConfig config{}; + if (baudrate <= 0) { - return -1; + return config; } static constexpr uint32_t H_CLK = 120000000; static constexpr uint32_t C_CLK = 48000000; if ((chip_type == TYPE_2232H) || (chip_type == TYPE_4232H) || (chip_type == TYPE_232H)) { if (baudrate * 10 > H_CLK / 0x3fff) { - best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, H_CLK, 10, &encoded_divisor); encoded_divisor |= 0x20000; /* switch on CLK/10*/ } else { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } } else if ((chip_type == TYPE_BM) || (chip_type == TYPE_2232C) || (chip_type == TYPE_R) || (chip_type == TYPE_230X)) { - best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); + config.best_baud = ftdi_to_clkbits(baudrate, C_CLK, 16, &encoded_divisor); } else { - best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); + config.best_baud = ftdi_to_clkbits_am(baudrate, &encoded_divisor); } - *value = (uint16_t) (encoded_divisor & 0xFFFF); + config.value = (uint16_t) (encoded_divisor & 0xFFFF); if (chip_type == TYPE_2232H || chip_type == TYPE_4232H || chip_type == TYPE_232H) { - *index = (uint16_t) (encoded_divisor >> 8); - *index &= 0xFF00; - *index |= (channel_index + 1); + config.ftdi_index = (uint16_t) (encoded_divisor >> 8); + config.ftdi_index &= 0xFF00; + config.ftdi_index |= (channel_index + 1); } else { - *index = (uint16_t) (encoded_divisor >> 16); + config.ftdi_index = (uint16_t) (encoded_divisor >> 16); } - return best_baud; + return config; } static optional get_uart(const usb_config_desc_t *config_desc, uint8_t intf_idx) { @@ -261,140 +270,15 @@ std::vector USBUartTypeFT23XX::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -int USBUartTypeFT23XX::reset_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Reset failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Reset successful, setting baudrate..."); - this->set_baudrate_(channel); - } - }; - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Reset control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_baudrate_(USBUartChannel *channel, uint32_t baudrate) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set baudrate failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - } else { - ESP_LOGD(TAG, "Baudrate %d set, setting line properties...", channel->baud_rate_); - this->set_line_properties_(channel); - } - }; - if (baudrate == 0) { - baudrate = channel->baud_rate_; - } - uint16_t value, ftdi_index; - ftdi_convert_baudrate(baudrate, this->chip_type_, channel->index_, &value, &ftdi_index); - ESP_LOGD(TAG, "Baudrate: %d, value=0x%04X, ftdi_index=0x%04X", baudrate, value, ftdi_index); - uint16_t usb_index = (ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, value, usb_index, callback); - if (!ok) { - ESP_LOGE(TAG, "Set baudrate control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_line_properties_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set line properties failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Line properties set, setting modem control..."); - this->set_dtr_rts_(channel); - }; - - uint16_t value = channel->data_bits_; - - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - - switch (channel->stop_bits_) { - case UART_CONFIG_STOP_BITS_1: - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } - - value |= (0x00 << 14); - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set line properties control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - -int USBUartTypeFT23XX::set_dtr_rts_(USBUartChannel *channel) { - usb_host::transfer_cb_t callback = [channel, this](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGE(TAG, "Set modem control failed, status=%s", esp_err_to_name(status.error_code)); - channel->initialised_.store(false); - return; - } - ESP_LOGD(TAG, "Modem control set for channel %d, starting input...", channel->index_); - channel->initialised_.store(true); - this->start_input(channel); - uint8_t next_index = channel->index_ + 1; - if (next_index < this->channels_.size()) { - USBUartChannel *next_channel = this->channels_[next_index]; - ESP_LOGD(TAG, "Configuring next channel %d", next_channel->index_); - this->reset_(next_channel); - return; - } else { - ESP_LOGI(TAG, "All channels configured"); - } - }; - - bool ok = this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, - channel->cdc_dev_.bulk_interface_number + 1, callback); - if (!ok) { - ESP_LOGE(TAG, "Set modem control control_transfer submit failed"); - channel->initialised_.store(false); - return -1; - } - return 0; -} - void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { - if (!channel->initialised_.load() || channel->input_started_.load()) + if (!channel->initialised_.load()) + return; + + // Use compare_exchange_strong to avoid a check-then-act race: start_input() is called + // from both the USB task (self-restart on success) and the main loop (backpressure + // restart), so a plain load()/store() pair can let both threads submit a transfer. + auto started = false; + if (!channel->input_started_.compare_exchange_strong(started, true)) return; const auto *ep = channel->cdc_dev_.in_ep; @@ -406,53 +290,122 @@ void USBUartTypeFT23XX::start_input(USBUartChannel *channel) { return; } + // FTDI prepends a 2-byte modem/line status header to every bulk IN packet. size_t uart_data_len = (status.data_len > 2) ? (status.data_len - 2) : 0; if (uart_data_len > 0) { ESP_LOGV(TAG, "RX callback: Received %zu bytes, channel=%d", uart_data_len, channel->index_); if (!channel->dummy_receiver_) { - // Copy the entire received UART payload into the ring buffer in one - // operation to avoid per-byte overhead and reduce the chance of - // heap activity in hot paths. - channel->input_buffer_.push(status.data + 2, uart_data_len); + UsbDataChunk *chunk = this->chunk_pool_.allocate(); + if (chunk == nullptr) { + this->usb_data_queue_.increment_dropped_count(); + channel->input_started_.store(false); + // Queue is full — wake the main loop to drain it, then let read_array() + // retrigger start_input() rather than spinning here in the USB task. + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + return; + } + // Strip the 2-byte FTDI header before queuing. + memcpy(chunk->data, status.data + 2, uart_data_len); + chunk->length = static_cast(uart_data_len); + chunk->channel = channel; + this->usb_data_queue_.push(chunk); #ifdef USE_UART_DEBUGGER if (channel->debug_) { - // Debug path creates a temporary vector for logging only; this is - // acceptable because debug mode is opt-in and not used in release. uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, std::vector(status.data + 2, status.data + 2 + uart_data_len), ',', channel->debug_prefix_); } #endif + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); } - } else { + } else if (status.data_len >= 2) { ESP_LOGVV(TAG, "RX: Status packet, modem=0x%02X line=0x%02X, ch=%d", status.data[0], status.data[1], channel->index_); } channel->input_started_.store(false); - if (channel->dummy_receiver_ || - channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { - this->start_input(channel); - } + this->start_input(channel); }; - channel->input_started_.store(true); - this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); + if (!this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize)) { + ESP_LOGE(TAG, "RX transfer submission failed for ep=0x%02X", ep->bEndpointAddress); + channel->input_started_.store(false); + } } -void USBUartTypeFT23XX::enable_channels() { - if (!this->channels_.empty() && this->channels_[0]->initialised_.load()) { - this->reset_(this->channels_[0]); - } +void USBUartTypeFT23XX::on_rx_overflow(USBUartChannel *channel) { + ESP_LOGW(TAG, "RX buffer overflow on channel %d, clearing to resync", channel->index_); + channel->input_buffer_.clear(); +} - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - channel->input_started_.store(false); - channel->output_started_.store(false); +bool USBUartTypeFT23XX::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { + // On reload (settings change on an open channel) skip the SIO reset; the FTDI set_termios + // path only re-applies baud + line properties and does not re-assert DTR/RTS. + if (reload) + step++; + switch (step) { + case 0: // SIO reset (init only) + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x00, 0x00, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + case 1: { // set baudrate + auto config = ftdi_convert_baudrate(channel->baud_rate_, this->chip_type_, channel->index_); + uint16_t usb_index = (config.ftdi_index & 0xFF00) | (channel->cdc_dev_.bulk_interface_number + 1); + ESP_LOGD(TAG, "Baudrate: %u, value=0x%04X, ftdi_index=0x%04X", (unsigned) channel->baud_rate_, config.value, + config.ftdi_index); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x03, config.value, usb_index); + return true; + } + case 2: { // set line properties (data bits / parity / stop bits) + uint16_t value = channel->data_bits_; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + value |= (0x00 << 8); + break; + case UART_CONFIG_PARITY_ODD: + value |= (0x01 << 8); + break; + case UART_CONFIG_PARITY_EVEN: + value |= (0x02 << 8); + break; + case UART_CONFIG_PARITY_MARK: + value |= (0x03 << 8); + break; + case UART_CONFIG_PARITY_SPACE: + value |= (0x04 << 8); + break; + } + switch (channel->stop_bits_) { + default: // 1 bit + value |= (0x00 << 11); + break; + case UART_CONFIG_STOP_BITS_1_5: + value |= (0x01 << 11); + break; + case UART_CONFIG_STOP_BITS_2: + value |= (0x02 << 11); + break; + } + value |= (0x00 << 14); + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + } + case 3: // set modem control DTR+RTS (init only) + if (reload) + return false; + this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x01, 0x0000, + channel->cdc_dev_.bulk_interface_number + 1); + return true; + default: + return false; } } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32P4 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index a50f1cf2d4..3c7ecd9a83 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -1,7 +1,9 @@ -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" +#include namespace esphome::usb_uart { @@ -198,101 +200,116 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev return cdc_devs; } -void USBUartTypePL2303::enable_channels() { - if (this->channels_.empty()) - return; +// Vendor init sequence for non-HXN chips (mirrors pl2303_startup in the Linux driver): +// read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, write 0x0404=1, +// read 0x8484, read 0x8383, write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+). +// The final entry's wIndex is patched at runtime depending on the chip type. +struct Pl2303InitStep { + uint8_t type; + uint8_t request; + uint16_t value; + uint16_t index; + bool read; // reads need a 1-byte buffer to set wLength=1 so the IN data stage runs +}; +static const Pl2303InitStep PL2303_INIT[] = { + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 0, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0x0404, 1, false}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8484, 0, true}, + {VENDOR_READ_REQUEST_TYPE, VENDOR_READ_REQUEST, 0x8383, 0, true}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 0, 1, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 1, 0, false}, + {VENDOR_WRITE_REQUEST_TYPE, VENDOR_WRITE_REQUEST, 2, 0, false}, +}; +static constexpr uint8_t PL2303_INIT_COUNT = sizeof(PL2303_INIT) / sizeof(PL2303_INIT[0]); - auto *channel = this->channels_[0]; +bool USBUartTypePL2303::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { bool is_legacy = (this->chip_type_ == PL2303_TYPE_H); bool is_hxn = (this->chip_type_ == PL2303_TYPE_HXN); - usb_host::transfer_cb_t nop_cb = [](const usb_host::TransferStatus &status) { - if (!status.success) - ESP_LOGW(TAG, "PL2303: vendor init transfer failed"); - }; - - // Init sequence for non-HXN chips (mirrors pl2303_startup in Linux driver): - // Read 0x8484, write 0x0404=0, read 0x8484, read 0x8383, read 0x8484, - // write 0x0404=1, read 0x8484, read 0x8383, - // write 0=1, write 1=0, write 2=0x24 (legacy) or 0x44 (HX+) - if (!is_hxn) { - uint8_t req = VENDOR_READ_REQUEST; - uint8_t wreq = VENDOR_WRITE_REQUEST; - - // Fire-and-forget vendor reads: result discarded, chip requires this sequence. - // Pass a 1-byte buffer to set wLength=1 so the IN data stage is performed. - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 0, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0x0404, 1, nop_cb); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8484, 0, nop_cb, {0}); - this->control_transfer(VENDOR_READ_REQUEST_TYPE, req, 0x8383, 0, nop_cb, {0}); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 0, 1, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 1, 0, nop_cb); - this->control_transfer(VENDOR_WRITE_REQUEST_TYPE, wreq, 2, is_legacy ? 0x24 : 0x44, nop_cb); + // Vendor init burst runs only on full init for non-HXN chips. + uint8_t init_count = (!reload && !is_hxn) ? PL2303_INIT_COUNT : 0; + if (step < init_count) { + const auto &e = PL2303_INIT[step]; + uint16_t index = (step == PL2303_INIT_COUNT - 1) ? (is_legacy ? 0x24 : 0x44) : e.index; + this->config_transfer_(e.type, e.request, e.value, index, + e.read ? std::vector{0} : std::vector{}); + return true; } + step -= init_count; - // Build 7-byte line coding structure: - // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits - uint8_t line_coding[7] = {}; - uint32_t baud = channel->get_baud_rate(); - - // Choose baud encoding based on chip type - uint32_t nearest = nearest_supported_baud(baud); - if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { - encode_baud_direct(line_coding, baud); - } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { - encode_baud_divisor_alt(line_coding, baud); - } else { - encode_baud_divisor(line_coding, baud); - } - - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } - - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } - - // Data bits - line_coding[6] = channel->get_data_bits(); - - ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], - line_coding[6]); - - std::vector lc_vec(line_coding, line_coding + 7); uint16_t iface = channel->cdc_dev_.bulk_interface_number; - this->control_transfer(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, nop_cb, lc_vec); + switch (step) { + case 0: { + // Build 7-byte line coding structure: + // [0-3] baud rate (LE32), [4] stop bits, [5] parity, [6] data bits + uint8_t line_coding[7] = {}; + uint32_t baud = channel->get_baud_rate(); - // Assert DTR + RTS - this->control_transfer(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface, nop_cb); + // Choose baud encoding based on chip type + uint32_t nearest = nearest_supported_baud(baud); + if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { + encode_baud_direct(line_coding, baud); + } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { + encode_baud_divisor_alt(line_coding, baud); + } else { + encode_baud_divisor(line_coding, baud); + } - this->start_channels_(); + // Stop bits: 0=1, 1=1.5, 2=2 + switch (channel->get_stop_bits()) { + case 2: + line_coding[4] = 2; + break; + default: + line_coding[4] = 0; + break; + } + + // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space + switch (channel->parity_) { + case UART_CONFIG_PARITY_ODD: + line_coding[5] = 1; + break; + case UART_CONFIG_PARITY_EVEN: + line_coding[5] = 2; + break; + case UART_CONFIG_PARITY_MARK: + line_coding[5] = 3; + break; + case UART_CONFIG_PARITY_SPACE: + line_coding[5] = 4; + break; + default: + line_coding[5] = 0; + break; + } + + // Data bits + line_coding[6] = channel->get_data_bits(); + + ESP_LOGD(TAG, "PL2303: SET_LINE_REQUEST baud=%u stop=%u parity=%u data=%u", baud, line_coding[4], line_coding[5], + line_coding[6]); + + std::vector lc_vec(line_coding, line_coding + 7); + this->config_transfer_(SET_LINE_REQUEST_TYPE, SET_LINE_REQUEST, 0, iface, lc_vec); + return true; + } + case 1: + // Assert DTR + RTS (init only) + if (reload) + return false; + this->config_transfer_(SET_CONTROL_REQUEST_TYPE, SET_CONTROL_REQUEST, CONTROL_DTR | CONTROL_RTS, iface); + return true; + default: + return false; + } } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3fdf35a472..c289625f1a 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,10 +1,12 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/core/application.h" #include +#include namespace esphome::usb_uart { @@ -158,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) { } uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE); memcpy(chunk->data, data, chunk_len); - chunk->length = static_cast(chunk_len); + chunk->length = chunk_len; // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. this->output_queue_.push(chunk); @@ -212,6 +214,7 @@ bool USBUartChannel::read_array(uint8_t *data, size_t len) { void USBUartComponent::setup() { USBClient::setup(); } void USBUartComponent::loop() { bool had_work = this->process_usb_events_(); + had_work |= this->run_config_machine_(); // Process USB data from the lock-free queue UsbDataChunk *chunk; @@ -227,6 +230,11 @@ void USBUartComponent::loop() { } #endif + // If there is not enough space for the full chunk, let the device subclass + // handle it (e.g. FTDI clears the buffer to prevent mid-telegram corruption). + if (channel->input_buffer_.get_free_space() < chunk->length) { + this->on_rx_overflow(channel); + } // Push data to ring buffer (now safe in main loop) channel->input_buffer_.push(chunk->data, chunk->length); @@ -483,62 +491,185 @@ void USBUartTypeCdcAcm::on_disconnected() { USBClient::on_disconnected(); } -void USBUartTypeCdcAcm::enable_channels() { +bool USBUartTypeCdcAcm::config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, + const uint8_t *response) { static constexpr uint8_t CDC_REQUEST_TYPE = usb_host::USB_TYPE_CLASS | usb_host::USB_RECIP_INTERFACE; static constexpr uint8_t CDC_SET_LINE_CODING = 0x20; static constexpr uint8_t CDC_SET_CONTROL_LINE_STATE = 0x22; static constexpr uint16_t CDC_DTR_RTS = 0x0003; // D0=DTR, D1=RTS - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; - // Configure the bridge's UART parameters. A USB-UART bridge will not forward data - // at the correct speed until SET_LINE_CODING is sent; without it the UART may run - // at an indeterminate default rate so the NCP receives garbled bytes and never - // sends RSTACK. - uint32_t baud = channel->baud_rate_; - std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits - }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); - this->control_transfer( - CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, - [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_LINE_CODING failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_LINE_CODING OK"); - } - }, - line_coding); - // Assert DTR+RTS to signal DTE is present. - this->control_transfer(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, - channel->cdc_dev_.interrupt_interface_number, [](const usb_host::TransferStatus &status) { - if (!status.success) { - ESP_LOGW(TAG, "SET_CONTROL_LINE_STATE failed: %X", status.error_code); - } else { - ESP_LOGD(TAG, "SET_CONTROL_LINE_STATE (DTR+RTS) OK"); - } - }); + switch (step) { + case 0: { + // Configure the bridge's UART parameters. A USB-UART bridge will not forward data + // at the correct speed until SET_LINE_CODING is sent; without it the UART may run + // at an indeterminate default rate so the NCP receives garbled bytes and never + // sends RSTACK. + uint32_t baud = channel->baud_rate_; + std::vector line_coding = { + static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), + static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop + static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + static_cast(channel->data_bits_), // bDataBits + }; + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, + (unsigned) channel->parity_, channel->data_bits_); + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, + line_coding); + return true; + } + case 1: + // Assert DTR+RTS to signal DTE is present (init only). + if (reload) + return false; + this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_CONTROL_LINE_STATE, CDC_DTR_RTS, + channel->cdc_dev_.interrupt_interface_number); + return true; + default: + return false; } - this->start_channels_(); } -void USBUartTypeCdcAcm::start_channels_() { - for (auto *channel : this->channels_) { - if (!channel->initialised_.load()) - continue; +void USBUartComponent::enable_channels() { + this->cfg_single_ = nullptr; + this->cfg_pending_reload_ = nullptr; + this->cfg_channel_idx_ = 0; + this->start_config_(false); +} + +void USBUartComponent::apply_channel_settings(USBUartChannel *channel) { + if (this->cfg_active_) { + // A config sequence is already running. Defer this reload until it finishes to preserve + // the one-control-transfer-at-a-time guarantee (restarting mid-flight would let an + // in-flight callback complete against fresh state). The pending slot coalesces multiple + // requests; the channel's live settings are read when the reload eventually runs. + // Note: multiple channel reloads are not queued; only one pending reload is supported at a time. + this->cfg_pending_reload_ = channel; + return; + } + this->cfg_single_ = channel; + this->start_config_(true); +} + +void USBUartComponent::start_config_(bool reload) { + this->cfg_reload_ = reload; + this->cfg_device_phase_ = !reload; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_active_ = true; + this->enable_loop(); +} + +void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data) { + this->cfg_done_.store(false); + // The completion callback runs in the USB-task context: it only records the result and + // wakes the loop. The next transfer is issued from run_config_machine_() on the loop thread. + bool submitted = this->control_transfer( + type, request, value, index, + [this](const usb_host::TransferStatus &status) { + this->cfg_ok_ = status.success; + if (!status.success) { + ESP_LOGW(TAG, "Config control transfer failed: %s", esp_err_to_name(status.error_code)); + } else if (status.data_len > 0) { + memcpy(this->cfg_response_, status.data, std::min(status.data_len, sizeof(this->cfg_response_))); + } + // Release: publishes cfg_ok_/cfg_response_ before the loop observes cfg_done_. + this->cfg_done_.store(true, std::memory_order_release); + this->enable_loop_soon_any_context(); + App.wake_loop_threadsafe(); + }, + data); + if (!submitted) { + // Submission failed (e.g. no free transfer request). No callback will fire, so synthesize + // a failed completion here so the state machine advances/aborts instead of hanging. + ESP_LOGW(TAG, "Config control transfer submit failed"); + this->cfg_ok_ = false; + this->cfg_done_.store(true, std::memory_order_release); + } +} + +bool USBUartComponent::run_config_machine_() { + if (!this->cfg_active_) + return false; + + if (this->cfg_in_flight_) { + // Acquire: pairs with the release in config_transfer_'s callback. + if (!this->cfg_done_.load(std::memory_order_acquire)) + return false; // still waiting; the callback will re-wake the loop (no busy spin) + this->cfg_in_flight_ = false; + this->cfg_done_.store(false); + this->cfg_step_++; + } + + // cfg_ok_ is now synchronized (we only get here on the initial entry or after observing + // cfg_done_ with acquire ordering), so it is safe to read. + ESP_LOGV(TAG, "Config machine: device_phase=%d channel_idx=%d step=%d reload=%d ok=%d", this->cfg_device_phase_, + this->cfg_channel_idx_, this->cfg_step_, this->cfg_reload_, this->cfg_ok_); + + // One-time device-level phase (init only). config_device_step() inspects cfg_ok_ itself. + if (this->cfg_device_phase_) { + if (this->config_device_step(this->cfg_step_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + this->cfg_device_phase_ = false; + this->cfg_step_ = 0; + this->cfg_ok_ = true; + } + + USBUartChannel *channel = + this->cfg_single_ != nullptr + ? this->cfg_single_ + : (this->cfg_channel_idx_ < this->channels_.size() ? this->channels_[this->cfg_channel_idx_] : nullptr); + + if (channel != nullptr && channel->initialised_.load()) { + if (!this->cfg_ok_) { + // A previous step in this channel's sequence failed. Abort the rest. On a full init, + // mark the channel uninitialised so data flow isn't started on a misconfigured channel; + // on a reload, leave the already-working channel as it was. + if (!this->cfg_reload_) + channel->initialised_.store(false); + } else if (this->config_step(channel, this->cfg_step_, this->cfg_reload_, this->cfg_ok_, this->cfg_response_)) { + this->cfg_in_flight_ = true; + return true; + } + } + + // Channel finished (or aborted). On full init, kick off data flow if still initialised. + if (channel != nullptr && !this->cfg_reload_ && channel->initialised_.load()) { channel->input_started_.store(false); channel->output_started_.store(false); this->start_input(channel); } + + // Advance to the next channel (or finish). + this->cfg_step_ = 0; + this->cfg_ok_ = true; + if (this->cfg_single_ != nullptr) { + this->cfg_active_ = false; + this->cfg_single_ = nullptr; + } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { + this->cfg_active_ = false; + } + + // If the machine just went idle and a reload was requested while it was busy, start it now. + if (!this->cfg_active_ && this->cfg_pending_reload_ != nullptr) { + this->cfg_single_ = this->cfg_pending_reload_; + this->cfg_pending_reload_ = nullptr; + this->start_config_(true); + } + return true; +} + +void USBUartChannel::load_settings(bool /*dump_config*/) { + // The per-channel control transfers already log their values at debug level. + this->parent_->apply_channel_settings(this); } } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index d0dccf42b9..5bb4c97796 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" @@ -125,7 +126,7 @@ struct UsbOutputChunk { void release() {} }; -class USBUartChannel : public uart::UARTComponent, public Parented { +class USBUartChannel final : public uart::UARTComponent, public Parented { friend class USBUartComponent; friend class USBUartTypeCdcAcm; friend class USBUartTypeCP210X; @@ -145,7 +146,9 @@ class USBUartChannel : public uart::UARTComponent, public Parentedinput_buffer_.get_available(); } bool is_connected() override { return this->initialised_.load(); } uart::UARTFlushResult flush() override; - void check_logger_conflict() override {} + // Re-apply the current line settings (baud, parity, etc) to this already-open channel. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience void set_parity(UARTParityOptions parity) { this->parity_ = parity; } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } @@ -159,6 +162,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } protected: + void check_logger_conflict() override {} // Larger structures first (8+ bytes) RingBuffer input_buffer_; LockFreeQueue output_queue_; @@ -191,9 +195,19 @@ class USBUartComponent : public usb_host::USBClient { void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } - void start_input(USBUartChannel *channel); + virtual void start_input(USBUartChannel *channel); void start_output(USBUartChannel *channel); + // Begin configuring all channels (full initialisation). Called from on_connected(). + void enable_channels(); + // Re-apply line settings to a single, already-open channel (used by + // USBUartChannel::load_settings()). + void apply_channel_settings(USBUartChannel *channel); + + // Called from loop() when input_buffer_ has insufficient space for the incoming chunk. + // Default is a no-op; override in device-specific subclasses that need resync on overflow. + virtual void on_rx_overflow(USBUartChannel *channel) {} + // Lock-free data transfer from USB task to main loop static constexpr int USB_DATA_QUEUE_SIZE = 32; LockFreeQueue usb_data_queue_; @@ -201,7 +215,41 @@ class USBUartComponent : public usb_host::USBClient { EventPool chunk_pool_; protected: + // Issue one control transfer as part of the setup state machine. The completion + // callback (USB-task context) records the result/IN data, marks the step done and + // wakes the loop so run_config_machine_() advances on the loop thread. Call exactly + // once from config_step_()/config_device_step_() when issuing a step. + void config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const std::vector &data = {}); + // (Re)start the config state machine. reload=false runs full init over all channels; + // reload=true re-applies settings to cfg_single_ only. + void start_config_(bool reload); + // Advance the config state machine; called from loop(). Returns true if it did work. + bool run_config_machine_(); + + // Per-subclass per-channel settings sequence. For the given zero-based step, issue the + // next control transfer via config_transfer_() and return true, or return false when the + // channel has no more steps. reload=true ⇒ apply only baud/parity/stop/data (skip + // enable/reset/DTR-RTS). ok/response carry the previous step's result and IN data. + virtual bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) = 0; + // Optional one-time device-level setup run before the per-channel phase on init only + // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. + virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + std::vector channels_{}; + + // Config state machine + USBUartChannel *cfg_single_{nullptr}; // non-null: reload of a single channel + USBUartChannel *cfg_pending_reload_{nullptr}; // reload requested while the machine was busy + std::atomic cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads + uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads) + uint8_t cfg_channel_idx_{0}; + uint8_t cfg_step_{0}; + bool cfg_active_{false}; + bool cfg_reload_{false}; + bool cfg_device_phase_{false}; + bool cfg_in_flight_{false}; + bool cfg_ok_{true}; }; class USBUartTypeCdcAcm : public USBUartComponent { @@ -212,11 +260,7 @@ class USBUartTypeCdcAcm : public USBUartComponent { virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; - virtual void enable_channels(); - /// Resets per-channel transfer flags and posts the first bulk IN transfer. - /// Called by enable_channels() and by vendor-specific subclass overrides that - /// handle their own line-coding setup before starting data flow. - void start_channels_(); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { @@ -225,7 +269,7 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: @@ -233,11 +277,11 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { void dump_config() override; protected: - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: - void apply_line_settings_(); CH34xChipType chiptype_{CHIP_UNKNOWN}; const char *chip_name_{"unknown"}; uint8_t num_ports_{1}; @@ -247,16 +291,12 @@ class USBUartTypeFT23XX : public USBUartTypeCdcAcm { public: USBUartTypeFT23XX(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} - void start_input(USBUartChannel *channel); + void start_input(USBUartChannel *channel) override; + void on_rx_overflow(USBUartChannel *channel) override; protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; - - int reset_(USBUartChannel *channel); - int set_baudrate_(USBUartChannel *channel, uint32_t baudrate = 0); - int set_line_properties_(USBUartChannel *channel); - int set_dtr_rts_(USBUartChannel *channel); + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; uint8_t chip_type_{255}; }; @@ -279,11 +319,12 @@ class USBUartTypePL2303 : public USBUartTypeCdcAcm { protected: std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; - void enable_channels() override; + bool config_step(USBUartChannel *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; Pl2303ChipType chip_type_{PL2303_TYPE_UNKNOWN}; }; } // namespace esphome::usb_uart -#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index d82a9fdec2..7d98af402d 100644 --- a/esphome/components/valve/__init__.py +++ b/esphome/components/valve/__init__.py @@ -87,7 +87,9 @@ _VALVE_SCHEMA = ( { cv.GenerateID(): cv.declare_id(Valve), cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent), - cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True), + cv.Optional( + CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED + ): cv.one_of(*DEVICE_CLASSES, lower=True), cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h index 08c9f4e011..63d03a889b 100644 --- a/esphome/components/valve/automation.h +++ b/esphome/components/valve/automation.h @@ -6,7 +6,7 @@ namespace esphome::valve { -template class OpenAction : public Action { +template class OpenAction final : public Action { public: explicit OpenAction(Valve *valve) : valve_(valve) {} @@ -16,7 +16,7 @@ template class OpenAction : public Action { Valve *valve_; }; -template class CloseAction : public Action { +template class CloseAction final : public Action { public: explicit CloseAction(Valve *valve) : valve_(valve) {} @@ -26,7 +26,7 @@ template class CloseAction : public Action { Valve *valve_; }; -template class StopAction : public Action { +template class StopAction final : public Action { public: explicit StopAction(Valve *valve) : valve_(valve) {} @@ -36,7 +36,7 @@ template class StopAction : public Action { Valve *valve_; }; -template class ToggleAction : public Action { +template class ToggleAction final : public Action { public: explicit ToggleAction(Valve *valve) : valve_(valve) {} @@ -58,7 +58,7 @@ template class ToggleAction : public Action { // (e.g. `const T & &` if Ts already carries a reference, or `const const // T &` if Ts already carries a const). This keeps trigger args no-copy // regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class ControlAction : public Action { +template class ControlAction final : public Action { public: using ApplyFn = void (*)(ValveCall &, const std::remove_cvref_t &...); ControlAction(Valve *valve, ApplyFn apply) : valve_(valve), apply_(apply) {} @@ -74,7 +74,7 @@ template class ControlAction : public Action { ApplyFn apply_; }; -template class ValveIsOpenCondition : public Condition { +template class ValveIsOpenCondition final : public Condition { public: ValveIsOpenCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_open(); } @@ -83,7 +83,7 @@ template class ValveIsOpenCondition : public Condition { Valve *valve_; }; -template class ValveIsClosedCondition : public Condition { +template class ValveIsClosedCondition final : public Condition { public: ValveIsClosedCondition(Valve *valve) : valve_(valve) {} bool check(const Ts &...x) override { return this->valve_->is_fully_closed(); } @@ -92,7 +92,7 @@ template class ValveIsClosedCondition : public Condition Valve *valve_; }; -class ValveOpenTrigger : public Trigger<> { +class ValveOpenTrigger final : public Trigger<> { public: ValveOpenTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { @@ -106,7 +106,7 @@ class ValveOpenTrigger : public Trigger<> { Valve *valve_; }; -class ValveClosedTrigger : public Trigger<> { +class ValveClosedTrigger final : public Trigger<> { public: ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { a_valve->add_on_state_callback([this]() { diff --git a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h index 8d372f45d6..a77fc7f56a 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.h @@ -5,7 +5,7 @@ namespace esphome::vbus { -class DeltaSolBSPlusBSensor : public VBusListener, public Component { +class DeltaSolBSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_relay1_bsensor(binary_sensor::BinarySensor *bsensor) { this->relay1_bsensor_ = bsensor; } @@ -38,7 +38,7 @@ class DeltaSolBSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2009BSensor : public VBusListener, public Component { +class DeltaSolBS2009BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -59,7 +59,7 @@ class DeltaSolBS2009BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCBSensor : public VBusListener, public Component { +class DeltaSolCBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -76,7 +76,7 @@ class DeltaSolCBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS2BSensor : public VBusListener, public Component { +class DeltaSolCS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -93,7 +93,7 @@ class DeltaSolCS2BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCS4BSensor : public VBusListener, public Component { +class DeltaSolCS4BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -110,7 +110,7 @@ class DeltaSolCS4BSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolCSPlusBSensor : public VBusListener, public Component { +class DeltaSolCSPlusBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -127,7 +127,7 @@ class DeltaSolCSPlusBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class DeltaSolBS2BSensor : public VBusListener, public Component { +class DeltaSolBS2BSensor final : public VBusListener, public Component { public: void dump_config() override; void set_s1_error_bsensor(binary_sensor::BinarySensor *bsensor) { this->s1_error_bsensor_ = bsensor; } @@ -146,7 +146,7 @@ class DeltaSolBS2BSensor : public VBusListener, public Component { class VBusCustomSubBSensor; -class VBusCustomBSensor : public VBusListener, public Component { +class VBusCustomBSensor final : public VBusListener, public Component { public: void dump_config() override; void set_bsensors(std::vector bsensors) { this->bsensors_ = std::move(bsensors); }; @@ -156,7 +156,7 @@ class VBusCustomBSensor : public VBusListener, public Component { void handle_message(std::vector &message) override; }; -class VBusCustomSubBSensor : public binary_sensor::BinarySensor, public Component { +class VBusCustomSubBSensor final : public binary_sensor::BinarySensor, public Component { public: void set_message_parser(message_parser_t parser) { this->message_parser_ = std::move(parser); }; void parse_message(std::vector &message); diff --git a/esphome/components/vbus/vbus.h b/esphome/components/vbus/vbus.h index ff523178ef..c8cd0cb4a4 100644 --- a/esphome/components/vbus/vbus.h +++ b/esphome/components/vbus/vbus.h @@ -25,7 +25,7 @@ class VBusListener { virtual void handle_message(std::vector &message) = 0; }; -class VBus : public uart::UARTDevice, public Component { +class VBus final : public uart::UARTDevice, public Component { public: void dump_config() override; void loop() override; diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index fd6cf1e2ed..59892936b0 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -215,7 +215,7 @@ void VEML3235Sensor::dump_config() { " Auto-gain upper threshold: %f%%\n" " Auto-gain lower threshold: %f%%\n" " Values below will be used as initial values only", - this->auto_gain_threshold_high_ * 100.0, this->auto_gain_threshold_low_ * 100.0); + this->auto_gain_threshold_high_ * 100.0f, this->auto_gain_threshold_low_ * 100.0f); } ESP_LOGCONFIG(TAG, " Digital gain: %uX\n" diff --git a/esphome/components/veml3235/veml3235.h b/esphome/components/veml3235/veml3235.h index df88bc6ff5..cda6d177aa 100644 --- a/esphome/components/veml3235/veml3235.h +++ b/esphome/components/veml3235/veml3235.h @@ -59,7 +59,7 @@ enum VEML3235ComponentGain { VEML3235_GAIN_4X = 0b11, }; -class VEML3235Sensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VEML3235Sensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: void setup() override; void dump_config() override; diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 6ad2eb417f..d0d3584dc2 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_INFRARED, CONF_INTEGRATION_TIME, CONF_NAME, + DEVICE_CLASS_EMPTY, DEVICE_CLASS_ILLUMINANCE, ICON_BRIGHTNESS_5, ICON_BRIGHTNESS_6, @@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_6, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All( unit_of_measurement=UNIT_COUNTS, icon=ICON_BRIGHTNESS_7, accuracy_decimals=0, - device_class=DEVICE_CLASS_ILLUMINANCE, + device_class=DEVICE_CLASS_EMPTY, state_class=STATE_CLASS_MEASUREMENT, ), key=CONF_NAME, diff --git a/esphome/components/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 80e6f872ab..594c9da170 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -380,7 +380,7 @@ void VEML7700Component::apply_lux_compensation_(Readings &data) { // if this light level is exceeded" auto compensate = [&local_data](float &lux) { auto calculate_high_lux_compensation = [](float lux_veml) -> float { - return (((6.0135e-13 * lux_veml - 9.3924e-9) * lux_veml + 8.1488e-5) * lux_veml + 1.0023) * lux_veml; + return (((6.0135e-13f * lux_veml - 9.3924e-9f) * lux_veml + 8.1488e-5f) * lux_veml + 1.0023f) * lux_veml; }; if (lux > 1000.0f || local_data.actual_gain == Gain::X_1_8 || local_data.actual_gain == Gain::X_1_4) { diff --git a/esphome/components/veml7700/veml7700.h b/esphome/components/veml7700/veml7700.h index a036bdf002..4a1e25fb8a 100644 --- a/esphome/components/veml7700/veml7700.h +++ b/esphome/components/veml7700/veml7700.h @@ -95,7 +95,7 @@ union PSMRegister { } __attribute__((packed)); }; -class VEML7700Component : public PollingComponent, public i2c::I2CDevice { +class VEML7700Component final : public PollingComponent, public i2c::I2CDevice { public: // // EspHome framework functions diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.h b/esphome/components/vl53l0x/vl53l0x_sensor.h index 7c916f4fde..0aa01685c4 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.h +++ b/esphome/components/vl53l0x/vl53l0x_sensor.h @@ -22,7 +22,7 @@ struct SequenceStepTimeouts { enum VcselPeriodType { VCSEL_PERIOD_PRE_RANGE, VCSEL_PERIOD_FINAL_RANGE }; -class VL53L0XSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { +class VL53L0XSensor final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { public: VL53L0XSensor(); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 76b076a366..dd9d205aff 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -110,7 +110,7 @@ enum class MediaPlayerResponseState { }; #endif -class VoiceAssistant : public Component { +class VoiceAssistant final : public Component { public: VoiceAssistant(); @@ -353,7 +353,7 @@ class VoiceAssistant : public Component { #endif }; -template class StartAction : public Action, public Parented { +template class StartAction final : public Action, public Parented { TEMPLATABLE_VALUE(std::string, wake_word); public: @@ -368,22 +368,22 @@ template class StartAction : public Action, public Parent bool silence_detection_; }; -template class StartContinuousAction : public Action, public Parented { +template class StartContinuousAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_start(true, true); } }; -template class StopAction : public Action, public Parented { +template class StopAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->request_stop(); } }; -template class IsRunningCondition : public Condition, public Parented { +template class IsRunningCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_running() || this->parent_->is_continuous(); } }; -template class ConnectedCondition : public Condition, public Parented { +template class ConnectedCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->get_api_connection() != nullptr; } }; diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index b09e87e811..e1a4e4f4b0 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -8,7 +8,7 @@ DEPENDENCIES = ["network"] def AUTO_LOAD(): - if CORE.is_esp8266 or CORE.is_rp2040: + if CORE.is_esp8266 or CORE.is_rp2: return [] return ["socket"] diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index 84bc26e064..ddf3433e7d 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -11,7 +11,7 @@ namespace esphome::wake_on_lan { -class WakeOnLanButton : public button::Button, public Component { +class WakeOnLanButton final : public button::Button, public Component { public: void set_macaddr(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e, uint8_t f); diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index b05d7d4f6d..2063faeb91 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -9,7 +9,7 @@ #include "esp_idf_version.h" #include "esp_task_wdt.h" #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "hardware/watchdog.h" #include "pico/stdlib.h" #endif @@ -53,7 +53,7 @@ void WatchdogManager::set_timeout_(uint32_t timeout_ms) { esp_task_wdt_reconfigure(&wdt_config); #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 watchdog_enable(timeout_ms, true); #endif } @@ -65,7 +65,7 @@ uint32_t WatchdogManager::get_timeout_() { timeout_ms = (uint32_t) CONFIG_ESP_TASK_WDT_TIMEOUT_S * 1000; #endif // USE_ESP32 -#ifdef USE_RP2040 +#ifdef USE_RP2 timeout_ms = watchdog_get_count() / 1000; #endif diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py new file mode 100644 index 0000000000..b692b858a3 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -0,0 +1,84 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) + +CODEOWNERS = ["@latonita"] + +AUTO_LOAD = ["gpio_expander"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True + +waveshare_io_ch32v003_ns = cg.esphome_ns.namespace("waveshare_io_ch32v003") + +WaveshareIOCH32V003Component = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Component", cg.Component, i2c.I2CDevice +) +WaveshareIOCH32V003GPIOPin = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003GPIOPin", + cg.GPIOPin, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_WAVESHARE_IO_CH32V003 = "waveshare_io_ch32v003" +CONF_WAVESHARE_IO_CH32V003_ID = "waveshare_io_ch32v003_id" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(WaveshareIOCH32V003Component), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x24)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + +def validate_mode(value): + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +WAVESHARE_IO_PIN_SCHEMA = pins.gpio_base_schema( + WaveshareIOCH32V003GPIOPin, + cv.int_range(min=0, max=7), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, + invertible=True, +).extend( + { + cv.Required(CONF_WAVESHARE_IO_CH32V003): cv.use_id( + WaveshareIOCH32V003Component + ), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_WAVESHARE_IO_CH32V003, WAVESHARE_IO_PIN_SCHEMA) +async def waveshare_io_pin_to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + parent = await cg.get_variable(config[CONF_WAVESHARE_IO_CH32V003]) + + cg.add(var.set_parent(parent)) + + num = config[CONF_NUMBER] + cg.add(var.set_pin(num)) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output/__init__.py new file mode 100644 index 0000000000..9af9ce7e4b --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/__init__.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Output = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Output", + output.FloatOutput, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONF_SAFE_PWM_LEVELS = "safe_pwm_levels" + +DUTY_DEFAULT_MIN = 1 +DUTY_DEFAULT_MAX = 247 + + +def validate_pwm_limits(config): + """Validate that safe_pwm_levels.min_value <= safe_pwm_levels.max_value.""" + + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + if min_val > max_val: + raise cv.Invalid( + f"safe_pwm_levels.min_value ({min_val}) cannot be greater than " + f"safe_pwm_levels.max_value ({max_val})" + ) + return config + + +CONF_SAFE_PWM_LEVELS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_MIN_VALUE, default=DUTY_DEFAULT_MIN): cv.int_range( + min=0, max=255 + ), + cv.Optional(CONF_MAX_VALUE, default=DUTY_DEFAULT_MAX): cv.int_range( + min=0, max=255 + ), + } +) + +CONFIG_SCHEMA = cv.All( + output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(WaveshareIOCH32V003Output), + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_SAFE_PWM_LEVELS): CONF_SAFE_PWM_LEVELS_SCHEMA, + } + ), + validate_pwm_limits, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + min_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MIN_VALUE, DUTY_DEFAULT_MIN) + max_val = config.get(CONF_SAFE_PWM_LEVELS, {}).get(CONF_MAX_VALUE, DUTY_DEFAULT_MAX) + cg.add(var.set_pwm_safe_range(min_val, max_val)) diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp new file mode 100644 index 0000000000..30458310ce --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp @@ -0,0 +1,19 @@ +#include "waveshare_io_ch32v003_output.h" +#include "esphome/core/log.h" +#include + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.output"; + +void WaveshareIOCH32V003Output::write_state(float state) { + uint8_t pwm_value = static_cast(state * 255.0f); + uint8_t final_pwm_value = std::clamp(pwm_value, this->pwm_min_value_, this->pwm_max_value_); + if (final_pwm_value != pwm_value) { + ESP_LOGVV(TAG, "Clamping PWM value %u to safe range [%u, %u]", pwm_value, this->pwm_min_value_, + this->pwm_max_value_); + } + this->parent_->set_pwm_value(final_pwm_value); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h new file mode 100644 index 0000000000..abe8183692 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.h @@ -0,0 +1,22 @@ +#pragma once + +#include "../waveshare_io_ch32v003.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Output : public output::FloatOutput, public Parented { + public: + void set_pwm_safe_range(uint8_t min_value, uint8_t max_value) { + this->pwm_min_value_ = min_value; + this->pwm_max_value_ = max_value; + } + + protected: + void write_state(float state) override; + + uint8_t pwm_min_value_{1}; + uint8_t pwm_max_value_{247}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/__init__.py b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py new file mode 100644 index 0000000000..1e060bdfe4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import sensor, voltage_sampler +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_REFERENCE_VOLTAGE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_VOLT, +) + +from .. import ( + CONF_WAVESHARE_IO_CH32V003_ID, + WaveshareIOCH32V003Component, + waveshare_io_ch32v003_ns, +) + +AUTO_LOAD = ["voltage_sampler"] +DEPENDENCIES = ["waveshare_io_ch32v003"] + +WaveshareIOCH32V003Sensor = waveshare_io_ch32v003_ns.class_( + "WaveshareIOCH32V003Sensor", + sensor.Sensor, + cg.PollingComponent, + voltage_sampler.VoltageSampler, + cg.Parented.template(WaveshareIOCH32V003Component), +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + WaveshareIOCH32V003Sensor, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.GenerateID(CONF_WAVESHARE_IO_CH32V003_ID): cv.use_id( + WaveshareIOCH32V003Component + ), + cv.Optional(CONF_REFERENCE_VOLTAGE, default="9.9V"): cv.voltage, + } + ) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_WAVESHARE_IO_CH32V003_ID]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp new file mode 100644 index 0000000000..82da7451f9 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -0,0 +1,27 @@ +#include "waveshare_io_ch32v003_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const char *const TAG = "waveshare_io_ch32v003.sensor"; + +float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } + +void WaveshareIOCH32V003Sensor::dump_config() { + ESP_LOGCONFIG(TAG, + "WaveshareIOCH32V003Sensor:\n" + " Reference Voltage: %.2fV", + this->reference_voltage_); +} + +float WaveshareIOCH32V003Sensor::sample() { + uint16_t adc_value = this->parent_->get_adc_value(); + // Convert the ADC value to voltage. 10-bit ADC + float voltage = adc_value * this->reference_voltage_ / 1023.0f; + return voltage; +} + +void WaveshareIOCH32V003Sensor::update() { this->publish_state(this->sample()); } + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h new file mode 100644 index 0000000000..01beab5137 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/voltage_sampler/voltage_sampler.h" + +#include "../waveshare_io_ch32v003.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Sensor : public sensor::Sensor, + public PollingComponent, + public voltage_sampler::VoltageSampler, + public Parented { + public: + void set_reference_voltage(float reference_voltage) { this->reference_voltage_ = reference_voltage; } + + void update() override; + void dump_config() override; + float get_setup_priority() const override; + float sample() override; + + protected: + float reference_voltage_{9.9f}; // Default reference voltage for ADC calculations, can be overridden by user config +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp new file mode 100644 index 0000000000..8a58c7e7bb --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -0,0 +1,168 @@ +#include "waveshare_io_ch32v003.h" +#include "esphome/core/log.h" + +namespace esphome::waveshare_io_ch32v003 { + +static const uint8_t IO_EXTENSION_DIRECTION = 0x02; +static const uint8_t IO_EXTENSION_IO_OUTPUT_ADDR = 0x03; +static const uint8_t IO_EXTENSION_IO_INPUT_ADDR = 0x04; +static const uint8_t IO_EXTENSION_PWM_ADDR = 0x05; +static const uint8_t IO_EXTENSION_ADC_ADDR = 0x06; +static const uint8_t IO_EXTENSION_RTC_INT_ADDR = 0x07; + +static const char *const TAG = "waveshare_io_ch32v003"; + +void WaveshareIOCH32V003Component::setup() { + this->mode_mask_ = 0xFF; // Set all pins to output mode + this->output_mask_ = 0xFF; // Set all pins to high (output mode) + + bool step1 = this->write_gpio_modes_(); + bool step2 = this->write_gpio_outputs_(); + + if (!step1 || !step2) { + ESP_LOGE(TAG, "Failed to initialize Waveshare IO expander"); + this->mark_failed(); + return; + } + + this->disable_loop(); +} + +void WaveshareIOCH32V003Component::pin_mode(uint8_t pin, gpio::Flags flags) { + // bits: 0 = input, 1 = output + if (flags == gpio::FLAG_INPUT) { + // Clear mode mask bit + this->mode_mask_ &= ~(1 << pin); + this->enable_loop(); + } else if (flags == gpio::FLAG_OUTPUT) { + // Set mode mask bit + this->mode_mask_ |= 1 << pin; + } + this->write_gpio_modes_(); +} + +void WaveshareIOCH32V003Component::loop() { this->reset_pin_cache_(); } + +void WaveshareIOCH32V003Component::dump_config() { + ESP_LOGCONFIG(TAG, "WaveshareIO:"); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +uint16_t WaveshareIOCH32V003Component::get_adc_value() { + if (this->is_failed()) + return 0; + + uint8_t data[2]; + if (!this->read_bytes(IO_EXTENSION_ADC_ADDR, data, 2)) { + this->status_set_warning(LOG_STR("Failed to read ADC register")); + return 0; + } + uint16_t adc_value = (data[1] << 8) | data[0]; + this->status_clear_warning(); + return adc_value; +} + +uint8_t WaveshareIOCH32V003Component::get_rtc_interrupt_status() { + if (this->is_failed()) + return 0; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_RTC_INT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read RTC interrupt register")); + return 0; + } + this->status_clear_warning(); + return data; +} + +void WaveshareIOCH32V003Component::set_pwm_value(uint8_t value) { + if (this->is_failed()) + return; + + // PWM limits are enforced at the output component level to protect hardware + // based on circuit schematic requirements. This follows the pattern from the + // original Waveshare IO library function "void IO_EXTENSION_Pwm_Output(uint8_t Value)". + + if (!this->write_byte(IO_EXTENSION_PWM_ADDR, value)) { + this->status_set_warning(LOG_STR("Failed to set PWM duty cycle")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::write_gpio_modes_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_DIRECTION, this->mode_mask_)) { + this->status_set_warning(LOG_STR("Failed to write mode register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::write_gpio_outputs_() { + if (this->is_failed()) + return false; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, this->output_mask_)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool WaveshareIOCH32V003Component::digital_read_hw(uint8_t pin) { + if (this->is_failed()) + return false; + + uint8_t data = 0; + if (!this->read_bytes(IO_EXTENSION_IO_INPUT_ADDR, &data, 1)) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->input_mask_ = data; + + this->status_clear_warning(); + return true; +} + +void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) { + if (this->is_failed()) + return; + + if (value) { + this->output_mask_ |= (1 << pin); + } else { + this->output_mask_ &= ~(1 << pin); + } + + uint8_t data = this->output_mask_; + if (!this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data)) { + this->status_set_warning(LOG_STR("Failed to write output register")); + return; + } + + this->status_clear_warning(); +} + +bool WaveshareIOCH32V003Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); } +float WaveshareIOCH32V003Component::get_setup_priority() const { return setup_priority::IO; } + +void WaveshareIOCH32V003GPIOPin::setup() { this->pin_mode(this->flags_); } +void WaveshareIOCH32V003GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } +bool WaveshareIOCH32V003GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) ^ this->inverted_; } + +void WaveshareIOCH32V003GPIOPin::digital_write(bool value) { + this->parent_->digital_write(this->pin_, value ^ this->inverted_); +} + +size_t WaveshareIOCH32V003GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "EXIO%u via WaveshareIO", this->pin_); +} + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h new file mode 100644 index 0000000000..4a31602fa4 --- /dev/null +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.h @@ -0,0 +1,65 @@ +#pragma once + +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::waveshare_io_ch32v003 { + +class WaveshareIOCH32V003Component : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + WaveshareIOCH32V003Component() = default; + + void setup() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + void loop() override; + + uint16_t get_adc_value(); + uint8_t get_rtc_interrupt_status(); + void set_pwm_value(uint8_t value); // 0 - 255 + + protected: + friend class WaveshareIOCH32V003GPIOPin; + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + uint8_t mode_mask_{0x00}; // Mask for the pin mode - 1 means output, 0 means input + uint8_t output_mask_{0x00}; // The mask to write as output state - 1 means HIGH, 0 means LOW + uint8_t input_mask_{0x00}; // The state read in digital_read_hw - 1 means HIGH, 0 means LOW + + bool write_gpio_modes_(); + bool write_gpio_outputs_(); +}; + +/// Helper class to expose a WaveshareIO pin as a GPIO pin. +class WaveshareIOCH32V003GPIOPin : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{}; + bool inverted_{}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::waveshare_io_ch32v003 diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index fd380a38dd..2587d13b9e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations import gzip +import logging +import re import esphome.codegen as cg from esphome.components import web_server_base @@ -23,6 +25,7 @@ from esphome.const import ( CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, @@ -31,18 +34,24 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["json", "web_server_base"] +AUTH_TYPE_BASIC = "basic" +AUTH_TYPE_DIGEST = "digest" + CONF_SORTING_GROUP_ID = "sorting_group_id" CONF_SORTING_GROUPS = "sorting_groups" CONF_SORTING_WEIGHT = "sorting_weight" +CONF_ALLOWED_ORIGINS = "allowed_origins" web_server_ns = cg.esphome_ns.namespace("web_server") @@ -71,6 +80,28 @@ def default_url(config: ConfigType) -> ConfigType: return config +def validate_version_deprecated(config: ConfigType) -> ConfigType: + if config[CONF_VERSION] == 1: + _LOGGER.warning( + "Version 1 of 'web_server' is deprecated and will be removed in " + "2027.1.0. Please migrate to version 2 (the default) or version 3." + ) + return config + + +def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType: + # Remove before 2027.1.0: the default auth scheme changes from basic to digest. + if CONF_TYPE not in auth: + _LOGGER.warning( + "The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the " + "password over the network in an easily reversible form. The default will change " + "to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set " + "'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to " + "adopt the more secure scheme." + ) + return auth + + def validate_local(config: ConfigType) -> ConfigType: if CONF_LOCAL in config and config[CONF_VERSION] == 1: raise cv.Invalid("'local' is not supported in version 1") @@ -92,6 +123,41 @@ def validate_ota(config: ConfigType) -> ConfigType: return config +# An Origin header is always "scheme://host[:port]" with no path or trailing slash. +_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$") + + +def validate_origin(value: str) -> str: + # "*" is the wildcard that allows any origin. + if value == "*": + return value + value = cv.string_strict(value) + if not _ORIGIN_RE.match(value): + raise cv.Invalid( + f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no " + f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin." + ) + # Browsers send the scheme and host lowercased in the Origin header, so normalize to match. + return value.lower() + + +def validate_private_network_access(config: ConfigType) -> ConfigType: + # PNA preflights are always cross-origin, so they can only be authorized against the + # allowed_origins list. Enabling PNA without any origins would deny every PNA request. + if ( + config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS] + and config.get(CONF_ALLOWED_ORIGINS) is None + ): + raise cv.Invalid( + f"'{CONF_ALLOWED_ORIGINS}' must be set when " + f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is " + f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin " + f"but is not recommended.", + path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS], + ) + return config + + def validate_sorting_groups(config: ConfigType) -> ConfigType: if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3: raise cv.Invalid( @@ -160,7 +226,9 @@ sorting_group = { WEBSERVER_SORTING_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEB_SERVER): cv.Schema( + # The per-entity web_server block is cosmetic dashboard ordering — + # mark the whole block advanced; the children inherit via the cascade. + cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( @@ -187,16 +255,25 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CSS_INCLUDE): cv.file_, cv.Optional(CONF_JS_URL): cv.string, cv.Optional(CONF_JS_INCLUDE): cv.file_, - cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean, - cv.Optional(CONF_AUTH): cv.Schema( - { - cv.Required(CONF_USERNAME): cv.All( - cv.string_strict, cv.Length(min=1) - ), - cv.Required(CONF_PASSWORD): cv.sensitive( - cv.All(cv.string_strict, cv.Length(min=1)) - ), - } + cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean, + cv.Optional(CONF_ALLOWED_ORIGINS): cv.All( + cv.ensure_list(validate_origin), cv.Length(min=1) + ), + cv.Optional(CONF_AUTH): cv.All( + cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All( + cv.string_strict, cv.Length(min=1) + ), + cv.Required(CONF_PASSWORD): cv.sensitive( + cv.All(cv.string_strict, cv.Length(min=1)) + ), + cv.Optional(CONF_TYPE): cv.one_of( + AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True + ), + } + ), + validate_auth_type_deprecated, ), cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase @@ -215,14 +292,16 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), default_url, + validate_version_deprecated, validate_local, validate_sorting_groups, validate_ota, + validate_private_network_access, _consume_web_server_sockets, ) @@ -319,10 +398,18 @@ async def to_code(config): request_log_listener() # Request a log listener slot for web server log streaming if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") - if CONF_AUTH in config: + if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: + cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") + cg.add(var.set_allowed_origins(allowed_origins)) + if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") - cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME])) - cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD])) + # The scheme is fixed at build time so the unused Basic/Digest code path is compiled + # out. Basic is the current default (the absence of this define); an explicit + # 'type: digest' opts in early. Default changes to digest in 2027.1.0. + if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + cg.add_define("USE_WEBSERVER_AUTH_DIGEST") + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 909a27c81c..1e6c4e8c62 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -56,9 +56,8 @@ namespace esphome::web_server { static const char *const TAG = "web_server"; -// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) -static constexpr size_t PSTR_LOCAL_SIZE = 18; -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) +// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. +[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): @@ -164,36 +163,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; @@ -284,8 +256,10 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * } // used for logs plus the initial ping/config -void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { + // ESPAsyncWebServer's send() only accepts null-terminated strings + (void) message_len; this->send(message, event, id, reconnect); } @@ -306,10 +280,10 @@ void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const ch } } -void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, const char *event, uint32_t id, - uint32_t reconnect) { +void DeferredUpdateEventSourceList::try_send_nodefer(const char *message, size_t message_len, const char *event, + uint32_t id, uint32_t reconnect) { for (DeferredUpdateEventSource *dues : *this) { - dues->try_send_nodefer(message, event, id, reconnect); + dues->try_send_nodefer(message, message_len, event, id, reconnect); } } @@ -331,7 +305,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource // Configure reconnect timeout and send config // this should always go through since the AsyncEventSourceClient event queue is empty on connect auto message = ws->get_config_json(); - source->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + source->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -342,7 +316,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource auto group_msg = builder.serialize(); // up to 31 groups should be able to be queued initially without defer - source->try_send_nodefer(group_msg.c_str(), "sorting_group"); + source->try_send_nodefer(group_msg.c_str(), group_msg.size(), "sorting_group"); } #endif @@ -422,8 +396,8 @@ void WebServer::setup() { return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); - buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); - this->events_.try_send_nodefer(buf, "ping", millis(), 30000); + size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); + this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); } void WebServer::loop() { @@ -441,16 +415,16 @@ void WebServer::loop() { void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); + this->events_.try_send_nodefer(message, message_len, "log", millis()); } #endif void WebServer::dump_config() { + char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Web Server:\n" " Address: %s:%u", - network::get_use_address(), this->base_->get_port()); + network::get_use_address_to(addr_buf), this->base_->get_port()); } float WebServer::get_setup_priority() const { return setup_priority::WIFI - 1.0f; } @@ -482,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif +// Read a request header value portably across the Arduino and ESP-IDF web servers. +// Returns an empty string when the header is absent (only allocates when a value is present). +static std::string get_request_header(AsyncWebServerRequest *request, const char *name) { +#ifdef USE_ESP32 + // ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend. + optional value = request->get_header(name); + return value.has_value() ? std::move(*value) : std::string(); +#else + // ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend. + const AsyncWebHeader *header = request->getHeader(name); + return header != nullptr ? std::string(header->value().c_str()) : std::string(); +#endif +} + +bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) { + // No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow. + if (origin.empty()) + return true; + + // Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to. + // This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time. + const size_t scheme_sep = origin.find("://"); + if (scheme_sep != std::string::npos) { + const std::string host = get_request_header(request, "Host"); + if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + return true; + } + +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Otherwise the origin must be explicitly allowed via configuration. + for (const char *allowed_origin : this->allowed_origins_) { + // A single "*" entry allows any origin. + if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') + return true; + if (origin == allowed_origin) + return true; + } +#endif + return false; +} + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { + const std::string origin = get_request_header(request, "Origin"); + if (!this->is_request_origin_allowed_(request, origin)) { + request->send(403); + return; + } + AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F("")); + // Echo the specific origin back so the response is valid even when auth (credentials) is enabled. + response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); char mac_s[18]; @@ -603,9 +626,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix root[ESPHOME_F("value")] = value; } -template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state, - const T &value, JsonDetail start_config) { +template +static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, + JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; } @@ -1098,8 +1121,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1555,17 +1577,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[PSTR_LOCAL_SIZE]; char temp_buf[VALUE_ACCURACY_MAX_LEN]; if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) - opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); + opt.add(json_state_str(climate::climate_mode_to_string(m))); if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) - opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); + opt.add(json_state_str(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { @@ -1576,12 +1597,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) - opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); + opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) - opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); + opt.add(json_state_str(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); @@ -1597,26 +1618,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json } bool has_state = false; - root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode)); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action)); root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { root[ESPHOME_F("current_temperature")] = @@ -1720,8 +1741,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); + set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1802,8 +1822,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - char buf[PSTR_LOCAL_SIZE]; - root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); + root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) root[ESPHOME_F("position")] = obj->position; @@ -1888,9 +1907,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), - value, start_config); + set_json_icon_state_value(root, obj, "alarm-control-panel", + json_state_str(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1962,10 +1980,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) { json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; const auto mode = obj->get_mode(); - const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode)); + ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); @@ -1974,7 +1991,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea if (start_config == DETAIL_ALL) { JsonArray modes = root[ESPHOME_F("modes")].to(); for (auto m : traits.get_supported_modes()) - modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m))); + modes.add(json_state_str(water_heater::water_heater_mode_to_string(m))); root[ESPHOME_F("min_temp")] = traits.get_min_temperature(); root[ESPHOME_F("max_temp")] = traits.get_max_temperature(); root[ESPHOME_F("step")] = traits.get_target_temperature_step(); @@ -2302,8 +2319,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[PSTR_LOCAL_SIZE]; - set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)), + set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; @@ -2481,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { return; } +#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS + // Private Network Access preflight carries a cross-origin Origin by design; its handler does the + // origin check itself, so let it run before the general enforcement below. + if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { + this->handle_pna_cors_request(request); + return; + } +#endif + + // Reject cross-origin browser requests unless the origin is explicitly allowed. + if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) { + request->send(403); + return; + } + #if !defined(USE_ESP32) && defined(USE_ARDUINO) if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); @@ -2502,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif -#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS - if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) { - this->handle_pna_cors_request(request); - return; - } -#endif - // Parse URL for component routing // Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action) UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 25f8f8212d..0fbe4ec551 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; }; @@ -160,7 +160,8 @@ class DeferredUpdateEventSource final : public AsyncEventSource { void loop(); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); }; class DeferredUpdateEventSourceList final : public std::list { @@ -173,7 +174,8 @@ class DeferredUpdateEventSourceList final : public std::listexpose_log_ = expose_log; } +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + /** Set the origins that browsers are allowed to make cross-origin requests from. + * + * Requests without an `Origin` header (e.g. non-browser clients like curl or the native API) + * are always allowed. Requests whose `Origin` matches the address the device is served on + * (same-origin) are always allowed. Any other browser origin must appear in this list, or the + * request is rejected. A single "*" entry allows any origin. Each other entry must exactly match + * the requesting page's `Origin` header (e.g. "https://example.com"). + * + * This list is also used to authorize Private Network Access requests when that feature is enabled. + * + * @param origins The list of allowed origins. + */ + void set_allowed_origins(std::initializer_list origins) { this->allowed_origins_ = origins; } +#endif + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup the internal web server and register handlers. @@ -591,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand const char *js_include_{nullptr}; #endif bool expose_log_{true}; +#ifdef USE_WEBSERVER_ALLOWED_ORIGINS + // Extra origins allowed to make cross-origin browser requests ("*" means any origin). + // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. + FixedVector allowed_origins_; +#endif + + /// Check whether the given request Origin is permitted. Same-origin (matching the Host the + /// request was sent to) and requests without an Origin header are always allowed; any other + /// origin must be listed in allowed_origins. The caller passes the already-read Origin header. + bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin); private: #ifdef USE_SENSOR diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index b587841dfd..fc575d1c06 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -63,7 +63,7 @@ async def to_code(config): cg.add_library("ESP8266WiFi", None) if CORE.is_libretiny: CORE.add_platformio_option("lib_ignore", ["ESPAsyncTCP", "RPAsyncTCP"]) - if CORE.is_rp2040: + if CORE.is_rp2: # Ignore bundled AsyncTCP libraries - we use RPAsyncTCP from async_tcp component CORE.add_platformio_option( "lib_ignore", ["ESPAsyncTCP", "AsyncTCP", "AsyncTCP_RP2040W"] diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index c7162c139a..9657853a73 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler { bool check_auth(AsyncWebServerRequest *request) { bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); if (!success) { + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 request->requestAuthentication(); +#elif defined(USE_WEBSERVER_AUTH_DIGEST) + request->requestAuthentication(nullptr, true); +#else + request->requestAuthentication(nullptr, false); +#endif } return success; } @@ -88,7 +96,7 @@ class AuthMiddlewareHandler : public MiddlewareHandler { } // namespace internal -class WebServerBase { +class WebServerBase final { public: void init() { if (this->initialized_) { diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index c32acaf03e..bf5a8666dc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -16,6 +16,11 @@ #include "utils.h" #include "web_server_idf.h" +#ifdef USE_WEBSERVER_AUTH_DIGEST +#include +#include +#endif + #ifdef USE_WEBSERVER_OTA #include #include "multipart.h" // For parse_multipart_boundary and other utils @@ -32,15 +37,27 @@ namespace esphome::web_server_idf { +// Status strings not provided by esp_http_server.h +#ifndef HTTPD_401 +#define HTTPD_401 "401 Unauthorized" +#endif #ifndef HTTPD_409 #define HTTPD_409 "409 Conflict" #endif +#ifndef HTTPD_422 +#define HTTPD_422 "422 Unprocessable Entity" +#endif #define CRLF_STR "\r\n" #define CRLF_LEN (sizeof(CRLF_STR) - 1) static const char *const TAG = "web_server_idf"; +// Chunk size for streaming request bodies; matches the Arduino AsyncWebServer buffer size. +// Buffers of this size must live on the heap - the httpd task stack is too small. +static constexpr size_t RECV_CHUNK_SIZE = 1460; +static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog + // Global instance to avoid guard variable (saves 8 bytes) // This is initialized at program startup before any threads namespace { @@ -184,9 +201,10 @@ esp_err_t AsyncWebServer::request_post_handler(httpd_req_t *r) { return server->handle_multipart_upload_(r, content_type_char); #endif } else { - ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type_char); - // fallback to get handler to support backward compatibility - return AsyncWebServer::request_handler(r); + // Other content types (e.g. application/json) are delivered raw to a matching + // custom handler via handleBody(), like the Arduino AsyncWebServer does + auto *server = static_cast(r->user_ctx); + return server->handle_raw_body_(r, content_type_char); } } @@ -237,6 +255,51 @@ esp_err_t AsyncWebServer::request_handler_(AsyncWebServerRequest *request) const return ESP_ERR_NOT_FOUND; } +esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_type) { + AsyncWebServerRequest req(r); + AsyncWebHandler *handler = nullptr; + for (auto *h : this->handlers_) { + if (h->canHandle(&req)) { + handler = h; + break; + } + } + + if (handler == nullptr) { + ESP_LOGW(TAG, "Unsupported content type for POST: %s", content_type); + // fallback to get handler to support backward compatibility + return this->request_handler_(&req); + } + + const size_t total = r->content_len; + if (total > 0) { + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); + size_t bytes_since_yield = 0; + + for (size_t index = 0; index < total;) { + int recv_len = httpd_req_recv(r, buffer.get(), std::min(total - index, RECV_CHUNK_SIZE)); + + if (recv_len <= 0) { + httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, + nullptr); + return recv_len == HTTPD_SOCK_ERR_TIMEOUT ? ESP_ERR_TIMEOUT : ESP_FAIL; + } + + handler->handleBody(&req, reinterpret_cast(buffer.get()), recv_len, index, total); + index += recv_len; + bytes_since_yield += recv_len; + + if (bytes_since_yield > YIELD_INTERVAL_BYTES) { + vTaskDelay(1); + bytes_since_yield = 0; + } + } + } + + handler->handleRequest(&req); + return ESP_OK; +} + AsyncWebServerRequest::~AsyncWebServerRequest() { delete this->rsp_; for (auto *param : this->params_) { @@ -276,12 +339,24 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code case 200: status = HTTPD_200; break; + case 204: + status = HTTPD_204; + break; + case 400: + status = HTTPD_400; + break; + case 401: + status = HTTPD_401; + break; case 404: status = HTTPD_404; break; case 409: status = HTTPD_409; break; + case 422: + status = HTTPD_422; + break; default: status = HTTPD_500; break; @@ -302,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } #ifdef USE_WEBSERVER_AUTH + +#ifdef USE_WEBSERVER_AUTH_DIGEST +namespace { + +// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated. +void bytes_to_hex(const uint8_t *data, size_t len, char *out) { + static const char HEX[] = "0123456789abcdef"; + for (size_t i = 0; i < len; i++) { + out[i * 2] = HEX[data[i] >> 4]; + out[i * 2 + 1] = HEX[data[i] & 0x0f]; + } + out[len * 2] = '\0'; +} + +// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated +// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent. +// Only whole parameter names match, so "nc" does not match inside "cnonce". +StringRef digest_param(StringRef params, const char *key) { + size_t key_len = strlen(key); + const char *base = params.c_str(); + size_t n = params.size(); + size_t i = 0; + while (i < n) { + while (i < n && (base[i] == ' ' || base[i] == ',')) + i++; + size_t name_start = i; + while (i < n && base[i] != '=' && base[i] != ',') + i++; + if (i >= n || base[i] == ',') + continue; // token without a '=', skip it + size_t name_len = i - name_start; + while (name_len > 0 && base[name_start + name_len - 1] == ' ') + name_len--; + i++; // consume '=' + const char *val_start; + size_t val_len; + if (i < n && base[i] == '"') { + i++; + val_start = base + i; + while (i < n && base[i] != '"') + i++; + val_len = (base + i) - val_start; + if (i < n) + i++; // consume closing quote + } else { + val_start = base + i; + while (i < n && base[i] != ',') + i++; + val_len = (base + i) - val_start; + } + if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0) + return StringRef(val_start, val_len); + while (i < n && base[i] != ',') + i++; + } + return StringRef(); +} + +// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which +// matches the ESPAsyncWebServer backend used on the Arduino platforms. +bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) { + const size_t prefix_len = sizeof("Digest ") - 1; + StringRef params(header.c_str() + prefix_len, header.size() - prefix_len); + + if (digest_param(params, "username") != username) + return false; + + StringRef realm = digest_param(params, "realm"); + StringRef nonce = digest_param(params, "nonce"); + StringRef uri = digest_param(params, "uri"); + StringRef qop = digest_param(params, "qop"); + StringRef nc = digest_param(params, "nc"); + StringRef cnonce = digest_param(params, "cnonce"); + StringRef response = digest_param(params, "response"); + if (response.size() != 32) + return false; + + // Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so + // nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters. + md5_context_t ctx; + uint8_t digest[16]; + + // HA1 = MD5(username:realm:password) -- uses the realm the client echoed back. + char ha1[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, username, strlen(username)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, realm.c_str(), realm.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, password, strlen(password)); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha1); + + // HA2 = MD5(method:uri) -- uses the uri the client echoed back. + char ha2[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, method, strlen(method)); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, uri.c_str(), uri.size()); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), ha2); + + // expected = MD5(HA1:nonce:nc:cnonce:qop:HA2) + char expected[33]; + esp_rom_md5_init(&ctx); + esp_rom_md5_update(&ctx, ha1, 32); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, nc.c_str(), nc.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, qop.c_str(), qop.size()); + esp_rom_md5_update(&ctx, ":", 1); + esp_rom_md5_update(&ctx, ha2, 32); + esp_rom_md5_final(digest, &ctx); + bytes_to_hex(digest, sizeof(digest), expected); + + // Constant-time comparison of the two 32-char hex digests. + uint8_t result = 0; + for (size_t i = 0; i < 32; i++) + result |= static_cast(expected[i] ^ response[i]); + return result == 0; +} + +} // namespace +#endif // USE_WEBSERVER_AUTH_DIGEST + bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const { if (username == nullptr || password == nullptr || *username == 0) { return true; @@ -313,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw auto *auth_str = auth.value().c_str(); +#ifdef USE_WEBSERVER_AUTH_DIGEST + // The build fixed the scheme to Digest, so the Basic path is compiled out entirely. + const auto auth_prefix_len = sizeof("Digest ") - 1; + if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) { + ESP_LOGW(TAG, "Only Digest authorization supported"); + return false; + } + return check_digest_auth(username, password, auth.value(), http_method_str(this->method())); +#else const auto auth_prefix_len = sizeof("Basic ") - 1; if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) { - ESP_LOGW(TAG, "Only Basic authorization supported yet"); + ESP_LOGW(TAG, "Only Basic authorization supported"); return false; } @@ -364,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw result |= static_cast(digest[i] ^ provided_ch); } return result == 0; +#endif // USE_WEBSERVER_AUTH_DIGEST } -void AsyncWebServerRequest::requestAuthentication(const char *realm) const { +void AsyncWebServerRequest::requestAuthentication() const { httpd_resp_set_hdr(*this, "Connection", "keep-alive"); - // Note: realm is never configured in ESPHome, always nullptr -> "Login Required" - (void) realm; // Unused - always use default +#ifdef USE_WEBSERVER_AUTH_DIGEST + // Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and + // does not defend against replay -- its purpose is to keep the password off the wire. + // The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer + // lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy). + uint8_t random_bytes[16]; + char nonce[33]; + char opaque[33]; + char header[160]; + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), nonce); + esp_fill_random(random_bytes, sizeof(random_bytes)); + bytes_to_hex(random_bytes, sizeof(random_bytes), opaque); + snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce, + opaque); + httpd_resp_set_hdr(*this, "WWW-Authenticate", header); +#else httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\""); +#endif // USE_WEBSERVER_AUTH_DIGEST httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr); } -#endif +#endif // USE_WEBSERVER_AUTH AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) { // Check cache first - only successful lookups are cached @@ -546,10 +776,11 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) -void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { +void AsyncEventSource::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, + uint32_t reconnect) { for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions - ses->try_send_nodefer(message, event, id, reconnect); + ses->try_send_nodefer(message, message_len, event, id, reconnect); } } } @@ -600,7 +831,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // tcp send buffer is empty on connect, so these should always go through auto message = ws->get_config_json(); - this->try_send_nodefer(message.c_str(), "ping", millis(), 30000); + this->try_send_nodefer(message.c_str(), message.size(), "ping", millis(), 30000); #ifdef USE_WEBSERVER_SORTING for (auto &group : ws->sorting_groups_) { @@ -612,7 +843,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { // a (very) large number of these should be able to be queued initially without defer // since the only thing in the send buffer at this point is the initial ping/config - this->try_send_nodefer(message.c_str(), "sorting_group"); + this->try_send_nodefer(message.c_str(), message.size(), "sorting_group"); } #endif @@ -647,7 +878,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); auto message = de.message_generator_(web_server_, de.source_); - if (this->try_send_nodefer(message.c_str(), "state")) { + if (this->try_send_nodefer(message.c_str(), message.size(), "state")) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); } else { @@ -718,7 +949,7 @@ void AsyncEventSourceResponse::loop() { this->entities_iterator_.advance(); } -bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char *event, uint32_t id, +bool AsyncEventSourceResponse::try_send_nodefer(const char *message, size_t message_len, const char *event, uint32_t id, uint32_t reconnect) { if (this->fd_.load() == 0) { return false; @@ -764,19 +995,18 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char // Fast path: check if message contains any newlines at all // Most SSE messages (JSON state updates) have no newlines - const char *first_n = strchr(message, '\n'); - const char *first_r = strchr(message, '\r'); + const char *first_n = static_cast(memchr(message, '\n', message_len)); + const char *first_r = static_cast(memchr(message, '\r', message_len)); if (first_n == nullptr && first_r == nullptr) { // No newlines - fast path (most common case) event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); + event_buffer_.append(message, message_len); event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator } else { // Has newlines - handle multi-line message const char *line_start = message; - size_t msg_len = strlen(message); - const char *msg_end = message + msg_len; + const char *msg_end = message + message_len; // Reuse the first search results const char *next_n = first_n; @@ -789,7 +1019,7 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char if (next_n == nullptr && next_r == nullptr) { // No more line breaks - output remaining text as final line event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(line_start); + event_buffer_.append(line_start, msg_end - line_start); event_buffer_.append(CRLF_STR, CRLF_LEN); break; } @@ -828,8 +1058,8 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char } // Search for next newlines only in remaining string - next_n = strchr(line_start, '\n'); - next_r = strchr(line_start, '\r'); + next_n = static_cast(memchr(line_start, '\n', msg_end - line_start)); + next_r = static_cast(memchr(line_start, '\r', msg_end - line_start)); } // Terminate message with blank line @@ -884,7 +1114,7 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e deq_push_back_with_dedup_(source, message_generator); } else { auto message = message_generator(web_server_, source); - if (!this->try_send_nodefer(message.c_str(), "state")) { + if (!this->try_send_nodefer(message.c_str(), message.size(), "state")) { deq_push_back_with_dedup_(source, message_generator); } } @@ -893,9 +1123,6 @@ void AsyncEventSourceResponse::deferrable_send_state(void *source, const char *e #ifdef USE_WEBSERVER_OTA esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *content_type) { - static constexpr size_t MULTIPART_CHUNK_SIZE = 1460; // Match Arduino AsyncWebServer buffer size - static constexpr size_t YIELD_INTERVAL_BYTES = 16 * 1024; // Yield every 16KB to prevent watchdog - // Parse boundary and create reader const char *boundary_start; size_t boundary_len; @@ -949,12 +1176,11 @@ esp_err_t AsyncWebServer::handle_multipart_upload_(httpd_req_t *r, const char *c } }); - // Use heap buffer - 1460 bytes is too large for the httpd task stack - auto buffer = std::make_unique_for_overwrite(MULTIPART_CHUNK_SIZE); + auto buffer = std::make_unique_for_overwrite(RECV_CHUNK_SIZE); size_t bytes_since_yield = 0; for (size_t remaining = r->content_len; remaining > 0;) { - int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, MULTIPART_CHUNK_SIZE)); + int recv_len = httpd_req_recv(r, buffer.get(), std::min(remaining, RECV_CHUNK_SIZE)); if (recv_len <= 0) { httpd_resp_send_err(r, recv_len == HTTPD_SOCK_ERR_TIMEOUT ? HTTPD_408_REQ_TIMEOUT : HTTPD_400_BAD_REQUEST, diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index c622d53e89..baa55898bb 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -129,7 +129,7 @@ class AsyncWebServerRequest { #ifdef USE_WEBSERVER_AUTH bool authenticate(const char *username, const char *password) const; // NOLINTNEXTLINE(readability-identifier-naming) - void requestAuthentication(const char *realm = nullptr) const; + void requestAuthentication() const; #endif void redirect(const std::string &url); @@ -233,6 +233,7 @@ class AsyncWebServer { static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd); + esp_err_t handle_raw_body_(httpd_req_t *r, const char *content_type); #ifdef USE_WEBSERVER_OTA esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); #endif @@ -291,7 +292,8 @@ class AsyncEventSourceResponse { friend class AsyncEventSource; public: - bool try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + bool try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); void loop(); @@ -343,7 +345,8 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); + void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, + uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); /// Returns true if there are sessions remaining (including pending cleanup). bool loop(); diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 6f38f58318..02a39d3c84 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -381,6 +381,15 @@ class WeikaiChannel : public uart::UARTComponent { /// we wait until all bytes are gone with a timeout of 100 ms uart::UARTFlushResult flush() override; +#if defined(USE_ESP8266) || defined(USE_ESP32) + /// @brief Re-apply the current line settings (baud, parity, etc) to the channel. + void load_settings(bool dump_config) override { + this->set_line_param_(); + this->set_baudrate_(); + } + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience +#endif + protected: friend class WeikaiComponent; diff --git a/esphome/components/weikai_i2c/weikai_i2c.h b/esphome/components/weikai_i2c/weikai_i2c.h index 940dbad9f2..6d8da031ac 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.h +++ b/esphome/components/weikai_i2c/weikai_i2c.h @@ -38,7 +38,7 @@ class WeikaiRegisterI2C : public weikai::WeikaiRegister { /// @brief The WeikaiComponentI2C class stores the information to the WeiKai component /// connected through an I2C bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentI2C : public weikai::WeikaiComponent, public i2c::I2CDevice { +class WeikaiComponentI2C final : public weikai::WeikaiComponent, public i2c::I2CDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_i2c_.register_ = reg; diff --git a/esphome/components/weikai_spi/weikai_spi.h b/esphome/components/weikai_spi/weikai_spi.h index 3b581ef44c..cdfa148c24 100644 --- a/esphome/components/weikai_spi/weikai_spi.h +++ b/esphome/components/weikai_spi/weikai_spi.h @@ -31,9 +31,9 @@ class WeikaiRegisterSPI : public weikai::WeikaiRegister { /// @brief The WeikaiComponentSPI class stores the information to the WeiKai component /// connected through an SPI bus. //////////////////////////////////////////////////////////////////////////////////// -class WeikaiComponentSPI : public weikai::WeikaiComponent, - public spi::SPIDevice { +class WeikaiComponentSPI final : public weikai::WeikaiComponent, + public spi::SPIDevice { public: weikai::WeikaiRegister ®(uint8_t reg, uint8_t channel) override { reg_spi_.register_ = reg; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index 03b4cf21a8..b705ee95fa 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -16,7 +16,7 @@ const float WHIRLPOOL_DG11J1_3A_TEMP_MIN = 18.0; const float WHIRLPOOL_DG11J1_91_TEMP_MAX = 30.0; const float WHIRLPOOL_DG11J1_91_TEMP_MIN = 16.0; -class WhirlpoolClimate : public climate_ir::ClimateIR { +class WhirlpoolClimate final : public climate_ir::ClimateIR { public: WhirlpoolClimate(); diff --git a/esphome/components/whynter/whynter.h b/esphome/components/whynter/whynter.h index d67bfa8fa0..fa8f201b05 100644 --- a/esphome/components/whynter/whynter.h +++ b/esphome/components/whynter/whynter.h @@ -12,7 +12,7 @@ const uint8_t TEMP_MAX_C = 32; // Celsius const uint8_t TEMP_MIN_F = 61; // Fahrenheit const uint8_t TEMP_MAX_F = 89; // Fahrenheit -class Whynter : public climate_ir::ClimateIR { +class Whynter final : public climate_ir::ClimateIR { public: Whynter() : climate_ir::ClimateIR(TEMP_MIN_C, TEMP_MAX_C, 1.0, true, true, diff --git a/esphome/components/wiegand/wiegand.h b/esphome/components/wiegand/wiegand.h index 33d81ba086..079f02ed68 100644 --- a/esphome/components/wiegand/wiegand.h +++ b/esphome/components/wiegand/wiegand.h @@ -21,13 +21,13 @@ struct WiegandStore { static void d1_gpio_intr(WiegandStore *arg); }; -class WiegandTagTrigger : public Trigger {}; +class WiegandTagTrigger final : public Trigger {}; -class WiegandRawTrigger : public Trigger {}; +class WiegandRawTrigger final : public Trigger {}; -class WiegandKeyTrigger : public Trigger {}; +class WiegandKeyTrigger final : public Trigger {}; -class Wiegand : public key_provider::KeyProvider, public Component { +class Wiegand final : public key_provider::KeyProvider, public Component { public: float get_setup_priority() const override { return setup_priority::HARDWARE; } void setup() override; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b7719c80d1..137304c807 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,17 +1,20 @@ import logging import math +from typing import Any -from esphome import automation +from esphome import automation, preferences from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_ENABLED, CONF_USE_PSRAM from esphome.components.esp32 import ( add_idf_sdkconfig_option, const, get_esp32_variant, only_on_variant, + request_wifi, ) from esphome.components.network import ( + add_use_address, has_high_performance_networking, ip_address_literal, ) @@ -49,6 +52,7 @@ from esphome.const import ( CONF_REBOOT_TIMEOUT, CONF_SSID, CONF_STATIC_IP, + CONF_STORAGE, CONF_SUBNET, CONF_TIMEOUT, CONF_TTLS_PHASE_2, @@ -75,14 +79,20 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] -NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] +NO_WIFI_VARIANTS = [ + const.VARIANT_ESP32H2, + const.VARIANT_ESP32H4, + const.VARIANT_ESP32H21, + const.VARIANT_ESP32P4, +] def variant_has_wifi(variant: str) -> bool: """Return True if *variant* has a native WiFi PHY. - Variants without a native PHY (ESP32-H2, ESP32-P4) need the - ``esp32_hosted`` co-processor to use ``wifi:``. + Variants without a native PHY (see ``NO_WIFI_VARIANTS`` — currently + ESP32-H2, ESP32-H4, ESP32-H21, ESP32-P4) need the ``esp32_hosted`` + co-processor to use ``wifi:``. Case-insensitive on *variant* so external callers can pass either the upstream uppercase form (e.g. ``"ESP32H2"`` from @@ -134,8 +144,8 @@ def has_native_wifi( """ if platform == Platform.ESP32: return variant_has_wifi(variant) if variant else True - if platform == Platform.RP2040: - from esphome.components.rp2040 import board_id_has_wifi + if platform == Platform.RP2: + from esphome.components.rp2 import board_id_has_wifi return board_id_has_wifi(board) if board else True return platform in _WIFI_FIRST_PLATFORMS @@ -292,7 +302,7 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) - if CONF_MANUAL_IP in config and CORE.is_rp2040: + if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " "The AP uses the default IP 192.168.4.1" @@ -315,8 +325,8 @@ def validate_variant(_): variant = get_esp32_variant() if variant in NO_WIFI_VARIANTS and "esp32_hosted" not in fv.full_config.get(): raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") - if CORE.is_rp2040: - from esphome.components.rp2040 import board_has_wifi, get_board + if CORE.is_rp2: + from esphome.components.rp2 import board_has_wifi, get_board if not board_has_wifi(): raise cv.Invalid( @@ -360,7 +370,7 @@ def _consume_wifi_sockets(config: ConfigType) -> ConfigType: DHCP/DNS). On ESP32, CONFIG_LWIP_MAX_SOCKETS only controls the POSIX socket layer — DHCP/DNS use raw udp_new() which bypasses it entirely. """ - if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2040): + if not (CORE.is_bk72xx or CORE.is_rtl87xx or CORE.is_ln882x or CORE.is_rp2): return config from esphome.components import socket @@ -426,7 +436,39 @@ def _validate(config): return config +def _report_provisioning_credentials(config): + """Report baked-in STA credentials to the provisioning component (if used). + + `_validate` has already folded any ``ssid``/``password`` into ``networks``, so a + non-empty list means credentials are set in the config. `provisioning:` warns + about this, since a device that uses a provisioning window should get its + credentials on first connection instead. + """ + if config.get(CONF_NETWORKS): + from esphome.components import provisioning + + provisioning.report_hardcoded_credentials("wifi") + return config + + CONF_PASSIVE_SCAN = "passive_scan" + +FAST_CONNECT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + **preferences.storage_schema(), + } +) + + +def _fast_connect_schema(value: Any) -> ConfigType: + """Accept the historic plain boolean (including boolean-like strings from + substitutions) or a dict with enabled/storage keys.""" + if not isinstance(value, dict): + value = {CONF_ENABLED: value} + return FAST_CONNECT_SCHEMA(value) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -447,12 +489,12 @@ CONFIG_SCHEMA = cv.All( CONF_POWER_SAVE_MODE, esp8266="none", esp32="light", - rp2040="light", + rp2="light", bk72xx="none", rtl87xx="none", ln882x="light", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), - cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, + cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, @@ -490,6 +532,7 @@ CONFIG_SCHEMA = cv.All( ), _apply_min_auth_mode_default, _validate, + _report_provisioning_credentials, ) @@ -559,7 +602,7 @@ def wifi_network(config, ap, static_ip): @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) - cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + add_use_address(var, config[CONF_USE_ADDRESS]) # Track if any network uses Enterprise authentication has_eap = False @@ -594,9 +637,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: @@ -610,8 +655,14 @@ async def to_code(config): cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) if CONF_MIN_AUTH_MODE in config: cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) - if config[CONF_FAST_CONNECT]: + fast_connect = config[CONF_FAST_CONNECT] + if fast_connect[CONF_ENABLED]: cg.add_define("USE_WIFI_FAST_CONNECT") + # The storage default preserves this preference's historic location: + # ESP8266 has always used RTC memory; every other platform effectively + # used flash (the in_flash flag was previously ignored outside ESP8266). + if preferences.is_in_flash(fast_connect[CONF_STORAGE]): + cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) @@ -642,7 +693,7 @@ async def to_code(config): if CONF_PHY_MODE in config: cg.add_define("USE_WIFI_PHY_MODE") cg.add(var.set_phy_mode(config[CONF_PHY_MODE])) - elif CORE.is_rp2040: + elif CORE.is_rp2: cg.add_library("WiFi", None) if CORE.is_esp32: @@ -761,6 +812,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 +843,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 +889,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) @@ -894,7 +961,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO}, + "wifi_component_pico_w.cpp": {PlatformFramework.RP2_ARDUINO}, } ) diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index 1ad69b3992..e63faa18ab 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -6,32 +6,32 @@ namespace esphome::wifi { -template class WiFiConnectedCondition : public Condition { +template class WiFiConnectedCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } }; -template class WiFiEnabledCondition : public Condition { +template class WiFiEnabledCondition final : public Condition { public: bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } }; -template class WiFiAPActiveCondition : public Condition { +template class WiFiAPActiveCondition final : public Condition { public: bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } }; -template class WiFiEnableAction : public Action { +template class WiFiEnableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->enable(); } }; -template class WiFiDisableAction : public Action { +template class WiFiDisableAction final : public Action { public: void play(const Ts &...x) override { global_wifi_component->disable(); } }; -template class WiFiConfigureAction : public Action, public Component { +template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) TEMPLATABLE_VALUE(std::string, password) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 07cb2ac243..44e3cb6af9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -45,6 +45,10 @@ #include "esphome/components/improv_serial/improv_serial_component.h" #endif +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif + namespace esphome::wifi { static const char *const TAG = "wifi"; @@ -649,7 +653,13 @@ void WiFiComponent::start() { this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT - this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); +#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH + const bool fast_connect_in_flash = true; +#else + const bool fast_connect_in_flash = false; +#endif + this->fast_connect_pref_ = + global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif SavedWifiSettings save{}; @@ -822,7 +832,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); } } @@ -866,8 +876,20 @@ void WiFiComponent::loop() { if (!this->has_ap() && this->reboot_timeout_ != 0) { if (now - this->last_connected_ > this->reboot_timeout_) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); + bool suppress = false; +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress = provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); + } } } } @@ -2382,7 +2404,7 @@ void WiFiComponent::clear_roaming_state_() { void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index d0521e548a..23b7558564 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 @@ -44,7 +46,7 @@ extern "C" { #endif #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 extern "C" { #include "cyw43.h" #include "cyw43_country.h" @@ -179,7 +181,7 @@ static constexpr size_t WIFI_SCAN_RESULT_FILTERED_RESERVE = 8; // Use std::vector for RP2040 (callback-based) and ESP32 (destructive scan API) // Use FixedVector for ESP8266 and LibreTiny where two-pass exact allocation is possible -#if defined(USE_RP2040) || defined(USE_ESP32) +#if defined(USE_RP2) || defined(USE_ESP32) template using wifi_scan_vector_t = std::vector; #else template using wifi_scan_vector_t = FixedVector; @@ -499,6 +501,8 @@ class WiFiComponent final : public Component { network::IPAddress get_dns_address(int num); network::IPAddresses get_ip_addresses(); + /// Returns nullptr when no explicit use_address is configured and the address is + /// derived at runtime from the device name (see network::get_use_address_to()). const char *get_use_address() const { return this->use_address_; } void set_use_address(const char *use_address) { this->use_address_ = use_address; } @@ -604,6 +608,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 +779,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_(); @@ -761,7 +817,7 @@ class WiFiComponent final : public Component { friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); #endif @@ -845,6 +901,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 */ @@ -935,7 +998,7 @@ class WiFiComponent final : public Component { private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. - const char *use_address_{""}; + const char *use_address_{nullptr}; }; extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 596fd2729b..1a70f81a2b 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -1,7 +1,7 @@ #include "wifi_component.h" #ifdef USE_WIFI -#ifdef USE_RP2040 +#ifdef USE_RP2 #include diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 9ff4cc54a0..af41465e71 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -10,9 +10,9 @@ namespace esphome::wifi_signal { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { #else -class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +class WiFiSignalSensor final : public sensor::Sensor, public PollingComponent { #endif public: #ifdef USE_WIFI_CONNECT_STATE_LISTENERS diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c11d592cd1..1fda802415 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -32,7 +32,7 @@ struct AllowedIP { }; /// Main Wireguard component class. -class Wireguard : public PollingComponent { +class Wireguard final : public PollingComponent { public: void setup() override; void loop() override; @@ -165,25 +165,26 @@ static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; void mask_key_to(char *buffer, size_t len, const char *key); /// Condition to check if remote peer is online. -template class WireguardPeerOnlineCondition : public Condition, public Parented { +template +class WireguardPeerOnlineCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_peer_up(); } }; /// Condition to check if Wireguard component is enabled. -template class WireguardEnabledCondition : public Condition, public Parented { +template class WireguardEnabledCondition final : public Condition, public Parented { public: bool check(const Ts &...x) override { return this->parent_->is_enabled(); } }; /// Action to enable Wireguard component. -template class WireguardEnableAction : public Action, public Parented { +template class WireguardEnableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->enable(); } }; /// Action to disable Wireguard component. -template class WireguardDisableAction : public Action, public Parented { +template class WireguardDisableAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->disable(); } }; diff --git a/esphome/components/wl_134/wl_134.h b/esphome/components/wl_134/wl_134.h index 973e5a1e7c..fad64bd8ff 100644 --- a/esphome/components/wl_134/wl_134.h +++ b/esphome/components/wl_134/wl_134.h @@ -8,7 +8,7 @@ namespace esphome::wl_134 { -class Wl134Component : public text_sensor::TextSensor, public Component, public uart::UARTDevice { +class Wl134Component final : public text_sensor::TextSensor, public Component, public uart::UARTDevice { public: enum Rfid134Error { RFID134_ERROR_NONE, diff --git a/esphome/components/wts01/wts01.h b/esphome/components/wts01/wts01.h index 17d4dc57a2..2a284ac86e 100644 --- a/esphome/components/wts01/wts01.h +++ b/esphome/components/wts01/wts01.h @@ -8,7 +8,7 @@ namespace esphome::wts01 { constexpr uint8_t PACKET_SIZE = 9; -class WTS01Sensor : public sensor::Sensor, public uart::UARTDevice, public Component { +class WTS01Sensor final : public sensor::Sensor, public uart::UARTDevice, public Component { public: void loop() override; void dump_config() override; diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index 52ce328b3c..b0ad79e51c 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -44,7 +44,7 @@ void X9cOutput::setup() { this->ud_pin_->get_pin(); this->ud_pin_->setup(); - if (this->initial_value_ <= 0.50) { + if (this->initial_value_ <= 0.50f) { this->trim_value(-101); // Set min value (beyond 0) this->trim_value(lroundf(this->initial_value_ * 100)); } else { diff --git a/esphome/components/x9c/x9c.h b/esphome/components/x9c/x9c.h index 112f0405d7..1cea15c26f 100644 --- a/esphome/components/x9c/x9c.h +++ b/esphome/components/x9c/x9c.h @@ -6,7 +6,7 @@ namespace esphome::x9c { -class X9cOutput : public output::FloatOutput, public Component { +class X9cOutput final : public output::FloatOutput, public Component { public: void set_cs_pin(InternalGPIOPin *pin) { cs_pin_ = pin; } void set_inc_pin(InternalGPIOPin *pin) { inc_pin_ = pin; } diff --git a/esphome/components/xdb401/xdb401.h b/esphome/components/xdb401/xdb401.h index 674d26fe8e..670425e69e 100644 --- a/esphome/components/xdb401/xdb401.h +++ b/esphome/components/xdb401/xdb401.h @@ -6,7 +6,7 @@ namespace esphome::xdb401 { -class XDB401Component : public PollingComponent, public i2c::I2CDevice { +class XDB401Component final : public PollingComponent, public i2c::I2CDevice { public: void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { this->pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/xgzp68xx/xgzp68xx.h b/esphome/components/xgzp68xx/xgzp68xx.h index 1bab9b091a..d9aec6e5cc 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.h +++ b/esphome/components/xgzp68xx/xgzp68xx.h @@ -20,7 +20,7 @@ enum XGZP68XXOversampling : uint8_t { XGZP68XX_OVERSAMPLING_UNKNOWN = (uint8_t) -1, }; -class XGZP68XXComponent : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { +class XGZP68XXComponent final : public PollingComponent, public sensor::Sensor, public i2c::I2CDevice { public: SUB_SENSOR(temperature) SUB_SENSOR(pressure) diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.h b/esphome/components/xiaomi_ble/xiaomi_ble.h index a4ecca0c66..1ebcf0e2f5 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.h +++ b/esphome/components/xiaomi_ble/xiaomi_ble.h @@ -72,7 +72,7 @@ optional parse_xiaomi_header(const esp32_ble_tracker::Service bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, const uint64_t &address); bool report_xiaomi_results(const optional &result, const char *address); -class XiaomiListener : public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiListener final : public esp32_ble_tracker::ESPBTDeviceListener { public: bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; }; diff --git a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h index 02d098c31b..36068ae227 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgdk2 { -class XiaomiCGDK2 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGDK2 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h index d49e3a08d1..7633458cb8 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_cgg1 { -class XiaomiCGG1 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGG1 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h index 28a7a3ae2d..0fa6c76e54 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_cgpr1 { -class XiaomiCGPR1 : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiCGPR1 final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h index e14077adb0..668133f364 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_gcls002 { -class XiaomiGCLS002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiGCLS002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h index 8bc6399065..cb53b47f6f 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccjcy01 { -class XiaomiHHCCJCY01 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY01 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h index 812e3a7d8f..fa2f461534 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.h @@ -8,7 +8,7 @@ namespace esphome::xiaomi_hhccjcy10 { -class XiaomiHHCCJCY10 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCJCY10 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } diff --git a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h index 2bdd6102be..3eda1b9859 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_hhccpot002 { -class XiaomiHHCCPOT002 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiHHCCPOT002 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h index aaf34f899f..122c6776c9 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_jqjcy01ym { -class XiaomiJQJCY01YM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiJQJCY01YM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h index e45596f966..09256047ae 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02 { -class XiaomiLYWSD02 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h index 23efcbf8fc..efd758b972 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd02mmc { -class XiaomiLYWSD02MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD02MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index a0a9260156..7aa4809e24 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiLYWSD03MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &device } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; diff --git a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h index 03462b850f..ecdbd412cb 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsd03mmc { -class XiaomiLYWSD03MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSD03MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h index e169afc651..86afef4571 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_lywsdcgq { -class XiaomiLYWSDCGQ : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiLYWSDCGQ final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h index daacd6be86..042a5034f1 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc303 { -class XiaomiMHOC303 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC303 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index 1cf0de14d3..958ac59bde 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -49,7 +49,7 @@ bool XiaomiMHOC401::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; diff --git a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h index 225c9ff189..3570f70a16 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_mhoc401 { -class XiaomiMHOC401 : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMHOC401 final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_miscale/xiaomi_miscale.h b/esphome/components/xiaomi_miscale/xiaomi_miscale.h index c75a22c9fb..3213f5d6de 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.h +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.h @@ -16,7 +16,7 @@ struct ParseResult { optional impedance; }; -class XiaomiMiscale : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMiscale final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; diff --git a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h index ee4ed52520..da02dee003 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_mjyd02yla { -class XiaomiMJYD02YLA : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMJYD02YLA final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h index a6d8abc5bf..4751e35e65 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.h @@ -9,9 +9,9 @@ namespace esphome::xiaomi_mue4094rt { -class XiaomiMUE4094RT : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiMUE4094RT final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h index cc6a334a20..0d3427cc4d 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.h @@ -15,7 +15,7 @@ namespace esphome::xiaomi_rtcgq02lm { -class XiaomiRTCGQ02LM : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiRTCGQ02LM final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const char *bindkey); diff --git a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h index 0b0cb8db0b..0573959473 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.h @@ -10,9 +10,9 @@ namespace esphome::xiaomi_wx08zm { -class XiaomiWX08ZM : public Component, - public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiWX08ZM final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index a4303b055a..c2b3ec1437 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -49,7 +49,7 @@ bool XiaomiXMWSDJ04MMC::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } if (res->humidity.has_value() && this->humidity_ != nullptr) { // see https://github.com/custom-components/sensor.mitemp_bt/issues/7#issuecomment-595948254 - *res->humidity = trunc(*res->humidity); + *res->humidity = truncf(*res->humidity); } if (!(xiaomi_ble::report_xiaomi_results(res, addr_str))) { continue; diff --git a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h index 9bab943ab9..c7d20aa356 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.h @@ -9,7 +9,7 @@ namespace esphome::xiaomi_xmwsdj04mmc { -class XiaomiXMWSDJ04MMC : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class XiaomiXMWSDJ04MMC final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(const char *bindkey); diff --git a/esphome/components/xl9535/xl9535.h b/esphome/components/xl9535/xl9535.h index 253ce76273..11fb3acc8d 100644 --- a/esphome/components/xl9535/xl9535.h +++ b/esphome/components/xl9535/xl9535.h @@ -17,7 +17,7 @@ enum { XL9535_CONFIG_PORT_1_REGISTER = 0x07, }; -class XL9535Component : public Component, public i2c::I2CDevice { +class XL9535Component final : public Component, public i2c::I2CDevice { public: bool digital_read(uint8_t pin); void digital_write(uint8_t pin, bool value); @@ -28,7 +28,7 @@ class XL9535Component : public Component, public i2c::I2CDevice { float get_setup_priority() const override { return setup_priority::IO; } }; -class XL9535GPIOPin : public GPIOPin { +class XL9535GPIOPin final : public GPIOPin { public: void set_parent(XL9535Component *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } 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/xpt2046/touchscreen/xpt2046.h b/esphome/components/xpt2046/touchscreen/xpt2046.h index f619e06fb7..8fe9b7cc43 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.h +++ b/esphome/components/xpt2046/touchscreen/xpt2046.h @@ -11,9 +11,9 @@ namespace esphome::xpt2046 { using namespace touchscreen; -class XPT2046Component : public Touchscreen, - public spi::SPIDevice { +class XPT2046Component final : public Touchscreen, + public spi::SPIDevice { public: /// Set the threshold for the touch detection. void set_threshold(int16_t threshold) { this->threshold_ = threshold; } diff --git a/esphome/components/yashima/yashima.h b/esphome/components/yashima/yashima.h index 336b28f5c5..864b3fce66 100644 --- a/esphome/components/yashima/yashima.h +++ b/esphome/components/yashima/yashima.h @@ -9,7 +9,7 @@ namespace esphome::yashima { -class YashimaClimate : public climate::Climate, public Component { +class YashimaClimate final : public climate::Climate, public Component { public: void setup() override; void set_transmitter(remote_transmitter::RemoteTransmitterComponent *transmitter) { diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 57f5778d54..b98f94d37a 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_BOARD, KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_cmake_cache from .const import ( CONF_CDC_ACM, @@ -17,6 +18,7 @@ from .const import ( KEY_OVERLAY, KEY_PM_STATIC, KEY_PRJ_CONF, + KEY_SYSBUILD, KEY_USER, KEY_ZEPHYR, zephyr_ns, @@ -54,6 +56,7 @@ class ZephyrData(TypedDict): pm_static: list[Section] user: dict[str, list[str]] kconfig: str + sysbuild: bool def zephyr_set_core_data(config: ConfigType) -> None: @@ -68,6 +71,10 @@ def zephyr_set_core_data(config: ConfigType) -> None: pm_static=[], user={}, kconfig="", + # When OTA is disabled, the image is built without a bootloader even if the + # config says `bootloader: mcuboot`, so the image can be smaller. This was + # the default behaviour in SDK 2.6.1. + sysbuild=False, ) @@ -76,7 +83,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_"): @@ -124,16 +134,14 @@ def zephyr_to_code(config: ConfigType) -> None: cg.add_define("USE_NATIVE_64BIT_TIME") cg.set_cpp_standard("gnu++20") # c++ support - zephyr_add_prj_conf("NEWLIB_LIBC", True) zephyr_add_prj_conf("FPU", True) - zephyr_add_prj_conf("NEWLIB_LIBC_FLOAT_PRINTF", True) zephyr_add_prj_conf("STD_CPP20", True) # random_bytes() uses sys_rand_get() which requires the entropy subsystem zephyr_add_prj_conf("ENTROPY_GENERATOR", True) # 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) @@ -200,7 +208,20 @@ def zephyr_add_user(key, value): user[key] += [value] -def copy_files(): +def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool: + """Write content to path, or remove a stale file when content is empty. + + Returns True if the file changed on disk. + """ + if content: + return write_file_if_changed(path, content) + if path.is_file(): + path.unlink() + return True + return False + + +def copy_files() -> None: user = zephyr_data()[KEY_USER] if user: entries = " ".join( @@ -216,6 +237,8 @@ def copy_files(): """ ) + changed = False + for image, want_opts in zephyr_data()[KEY_PRJ_CONF].items(): prj_conf = ( "\n".join( @@ -230,26 +253,25 @@ def copy_files(): else: path = CORE.relative_build_path("zephyr/prj.conf") - write_file_if_changed(CORE.relative_build_path(path), prj_conf) + changed |= write_file_if_changed(path, prj_conf) for image, content in zephyr_data()[KEY_OVERLAY].items(): if image: path = CORE.relative_build_path(f"sysbuild/{image}.overlay") else: path = CORE.relative_build_path("zephyr/app.overlay") - write_file_if_changed(path, content) + changed |= write_file_if_changed(path, content) for filename, path in zephyr_data()[KEY_EXTRA_BUILD_FILES].items(): - copy_file_if_changed( + changed |= copy_file_if_changed( path, CORE.relative_build_path(filename), ) pm_static = "\n".join(str(item) for item in zephyr_data()[KEY_PM_STATIC]) - if pm_static: - write_file_if_changed( - CORE.relative_build_path("zephyr/pm_static.yml"), pm_static - ) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/pm_static.yml"), pm_static + ) kconfig = zephyr_data()[KEY_KCONFIG] if kconfig: @@ -264,4 +286,19 @@ def copy_files(): + "\n" + kconfig ) - write_file_if_changed(CORE.relative_build_path("zephyr/Kconfig"), kconfig) + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/Kconfig"), kconfig + ) + + sysbuild_conf = "" + if zephyr_data()[KEY_SYSBUILD]: + sysbuild_conf = "SB_CONFIG_BOOTLOADER_MCUBOOT=y\n" + changed |= _write_file_if_changed_or_remove_when_empty( + CORE.relative_build_path("zephyr/sysbuild.conf"), sysbuild_conf + ) + + if changed: + # A configure-time input changed; drop the CMake cache so the build + # can't reuse stale configure results (the native sdk-nrf toolchain + # rebuilds pristine when the cache is missing). + clean_cmake_cache() diff --git a/esphome/components/zephyr/cdc_acm.h b/esphome/components/zephyr/cdc_acm.h index 4dc14397d8..9d11d4b575 100644 --- a/esphome/components/zephyr/cdc_acm.h +++ b/esphome/components/zephyr/cdc_acm.h @@ -7,7 +7,7 @@ namespace esphome::zephyr { -class CdcAcm : public Component { +class CdcAcm final : public Component { public: CdcAcm(); void setup() override; diff --git a/esphome/components/zephyr/const.py b/esphome/components/zephyr/const.py index f2de861e31..497e5f3ce5 100644 --- a/esphome/components/zephyr/const.py +++ b/esphome/components/zephyr/const.py @@ -13,6 +13,7 @@ KEY_PRJ_CONF: Final = "prj_conf" KEY_ZEPHYR = "zephyr" KEY_BOARD: Final = "board" KEY_USER: Final = "user" +KEY_SYSBUILD: Final = "sysbuild" zephyr_ns = cg.esphome_ns.namespace("zephyr") CdcAcm = zephyr_ns.class_("CdcAcm", cg.Component) diff --git a/esphome/components/zephyr/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 1d5b0f282b..1e4201d8f5 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,6 +1,7 @@ #ifdef USE_ZEPHYR #include "gpio.h" #include +#include #include "esphome/core/log.h" namespace esphome { @@ -33,20 +34,80 @@ static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) return ret; } +// ESPHome's InterruptType is expressed in logical levels, but the pin is configured active-high in Zephyr (inversion is +// applied in software by digital_read()/digital_write(), see the `!= inverted_` convention below). So when the pin is +// inverted we must swap the physical edge/level the interrupt arms on: a logical rising edge is a physical falling +// edge, etc. GPIO_INT_EDGE_BOTH is symmetric and needs no swap. +static gpio_flags_t interrupt_type_to_flags(gpio::InterruptType type, bool inverted) { + switch (type) { + case gpio::INTERRUPT_RISING_EDGE: + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; + case gpio::INTERRUPT_FALLING_EDGE: + return inverted ? GPIO_INT_EDGE_RISING : GPIO_INT_EDGE_FALLING; + case gpio::INTERRUPT_ANY_EDGE: + return GPIO_INT_EDGE_BOTH; + case gpio::INTERRUPT_LOW_LEVEL: + return inverted ? GPIO_INT_LEVEL_HIGH : GPIO_INT_LEVEL_LOW; + case gpio::INTERRUPT_HIGH_LEVEL: + return inverted ? GPIO_INT_LEVEL_LOW : GPIO_INT_LEVEL_HIGH; + } + return inverted ? GPIO_INT_EDGE_FALLING : GPIO_INT_EDGE_RISING; +} + +// Zephyr calls this with a pointer to the gpio_callback the interrupt fired on. +// Recover the owning ZephyrGPIOInterrupt and dispatch to the ESPHome ISR. +static void gpio_interrupt_handler(const device * /*dev*/, gpio_callback *cb, uint32_t /*pins*/) { + auto *interrupt = CONTAINER_OF(cb, ZephyrGPIOInterrupt, callback); + if (interrupt->func != nullptr) { + interrupt->func(interrupt->arg); + } +} + struct ISRPinArg { + const device *gpio; uint8_t pin; + uint8_t gpio_size; bool inverted; }; ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) + arg->gpio = this->gpio_; arg->pin = this->pin_; + arg->gpio_size = this->gpio_size_; arg->inverted = this->inverted_; return ISRInternalGPIOPin((void *) arg); } void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { - // TODO + if (!device_is_ready(this->gpio_)) { + ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); + return; + } + + // Drop any interrupt previously attached to this pin before re-registering. + this->detach_interrupt(); + + this->interrupt_.func = func; + this->interrupt_.arg = arg; + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_init_callback(&this->interrupt_.callback, gpio_interrupt_handler, BIT(port_pin)); + + int ret = gpio_add_callback(this->gpio_, &this->interrupt_.callback); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_add_callback failed for pin %u: %d", this->pin_, ret); + return; + } + + ret = gpio_pin_interrupt_configure(this->gpio_, port_pin, interrupt_type_to_flags(type, this->inverted_)); + if (ret != 0) { + ESP_LOGE(TAG, "gpio_pin_interrupt_configure failed for pin %u: %d", this->pin_, ret); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + return; + } + + ESP_LOGD(TAG, "Interrupt attached to pin %u (type=%d)", this->pin_, (int) type); } void ZephyrGPIOPin::setup() { @@ -88,15 +149,28 @@ void ZephyrGPIOPin::digital_write(bool value) { } gpio_pin_set(this->gpio_, this->pin_ % this->gpio_size_, value != this->inverted_ ? 1 : 0); } + void ZephyrGPIOPin::detach_interrupt() const { - // TODO + if (this->gpio_ == nullptr) { + return; + } + + uint8_t port_pin = this->pin_ % this->gpio_size_; + gpio_pin_interrupt_configure(this->gpio_, port_pin, GPIO_INT_DISABLE); + gpio_remove_callback(this->gpio_, &this->interrupt_.callback); + + this->interrupt_.func = nullptr; + this->interrupt_.arg = nullptr; } } // namespace zephyr bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { - // TODO - return false; + auto *arg = (zephyr::ISRPinArg *) this->arg_; + if (arg == nullptr || arg->gpio == nullptr) { + return false; + } + return bool(gpio_pin_get(arg->gpio, arg->pin % arg->gpio_size) != arg->inverted); } } // namespace esphome diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 907fbe9f9c..71d1620a67 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -3,9 +3,20 @@ #ifdef USE_ZEPHYR #include "esphome/core/hal.h" #include +#include namespace esphome::zephyr { -class ZephyrGPIOPin : public InternalGPIOPin { +// Bundles the Zephyr gpio_callback together with the ESPHome ISR function and +// argument. Keeping them in one POD struct lets the static handler recover the +// owning data straight from the callback pointer via CONTAINER_OF, so no global +// pin->instance lookup table is needed. +struct ZephyrGPIOInterrupt { + struct gpio_callback callback; + void (*func)(void *){nullptr}; + void *arg{nullptr}; +}; + +class ZephyrGPIOPin final : public InternalGPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; @@ -36,6 +47,10 @@ class ZephyrGPIOPin : public InternalGPIOPin { uint8_t gpio_size_{}; bool inverted_{}; bool value_{false}; + + // attach_interrupt()/detach_interrupt() are const (matching the base class), so + // the interrupt state they manage has to be mutable. + mutable ZephyrGPIOInterrupt interrupt_{}; }; } // namespace esphome::zephyr diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py new file mode 100644 index 0000000000..7654e63700 --- /dev/null +++ b/esphome/components/zephyr/library.py @@ -0,0 +1,180 @@ +"""Zephyr backend for the shared PlatformIO library converter. + +For each PlatformIO library added via ``cg.add_library()``, emit a Zephyr +external module (``zephyr/module.yml`` + ``zephyr/CMakeLists.txt`` built with the +``zephyr_library*`` API) into the shared ``pio_components`` cache. The caller +wires the resulting module directories into the build via +``EXTRA_ZEPHYR_MODULES``; Zephyr then compiles each module and links it into the +final image. + +Only framework-agnostic libraries (plain C/C++ that doesn't depend on the Arduino +API) will actually compile under Zephyr — this converter shares the +fetch/parse/cache plumbing, not API compatibility. +""" + +from pathlib import Path + +from esphome import yaml_util +from esphome.core import EsphomeError, Library +from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + SRC_FILE_EXTENSIONS, + ConvertedLibrary, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) + +# Zephyr libraries declare frameworks rarely and the PIO ``platforms`` token for +# nRF is seldom present, so the platform check is disabled (None) and only the +# framework mismatch warning fires. +ZEPHYR_FRAMEWORK = "zephyr" + + +def _escape(p: PathType) -> str: + # In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF + # backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' -- + # preserves content, so it's safe for arbitrary build flags (e.g. a -D value + # containing a backslash) as well as Windows paths. + return f'"{str(p)}"'.replace("\\", "\\\\") + + +def generate_module_yml(component: ConvertedLibrary) -> str: + """Render the ``zephyr/module.yml`` manifest for a converted library.""" + return yaml_util.dump( + { + "name": component.get_require_name(), + "build": {"cmake": "zephyr"}, + } + ) + + +def generate_cmakelists_txt(component: ConvertedLibrary) -> str: + """Render the ``zephyr/CMakeLists.txt`` that builds a converted library. + + Sources/includes are emitted as absolute paths since the CMakeLists lives in + the library's ``zephyr/`` subdir while its sources sit alongside it. Include + dirs are published globally so the app (and sibling libraries) can include the + library's headers, mirroring ESP-IDF's public ``INCLUDE_DIRS``. + """ + build = component.data.get("build", {}) + + build_src_dir = build.get("srcDir") + if not build_src_dir: + for d in ["src", "Src", "."]: + if (component.path / Path(d)).is_dir(): + build_src_dir = d + break + + build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) + build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER)) + build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS)) + + src_files = collect_filtered_files( + component.path / Path(build_src_dir), build_src_filter + ) + src_files = sorted( + str(Path(p).resolve()) + for p in src_files + if Path(p).suffix in SRC_FILE_EXTENSIONS + ) + + include_dir_flags, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None + ) + link_directories, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None + ) + link_libraries, build_flags = split_list_by_condition( + build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None + ) + + include_dirs = [build_include_dir, build_src_dir, *include_dir_flags] + include_dirs = [ + str((component.path / Path(d)).resolve()) + for d in include_dirs + if (component.path / Path(d)).is_dir() + ] + + lines = [f"zephyr_library_named({component.get_require_name()})"] + if src_files: + lines += [ + "zephyr_library_sources(", + *[f" {_escape(p)}" for p in src_files], + ")", + ] + if include_dirs: + lines += [ + "zephyr_include_directories(", + *[f" {_escape(p)}" for p in include_dirs], + ")", + ] + if build_flags: + lines += [ + "zephyr_library_compile_options(", + *[f" {_escape(f)}" for f in build_flags], + ")", + ] + # Best-effort link wiring; most Zephyr-portable libraries don't need it. + link_flags = [f"-L{d}" for d in link_directories] + [ + f"-l{lib}" for lib in link_libraries + ] + if link_flags: + lines += [ + "zephyr_link_libraries(", + *[f" {_escape(f)}" for f in link_flags], + ")", + ] + + return "\n".join(lines) + "\n" + + +def _emit_zephyr_module(component: ConvertedLibrary) -> None: + zephyr_dir = component.path / "zephyr" + write_file_if_changed(zephyr_dir / "module.yml", generate_module_yml(component)) + write_file_if_changed( + zephyr_dir / "CMakeLists.txt", generate_cmakelists_txt(component) + ) + + +def generate_zephyr_modules(libraries: list[Library]) -> list[Path]: + """Convert ``libraries`` to Zephyr modules and return all module directories. + + The returned list includes transitive dependencies (each converted library is + its own module). Every directory should be added to ``EXTRA_ZEPHYR_MODULES``; + Zephyr links all module libraries into the image, so cross-library symbols + resolve without explicit dependency declarations. + + Raises ``EsphomeError`` if two libraries resolve to the same Zephyr module + name -- each module's CMakeLists calls ``zephyr_library_named()``, so a + duplicate would otherwise fail the build with a CMake "target already exists". + The converter already warns when a library is referenced under inconsistent + specs (bare ``name`` vs ``owner/name``, git vs registry); this turns that into + an actionable error at the Zephyr boundary where it is fatal. + """ + module_dirs: list[Path] = [] + by_name: dict[str, Path] = {} + + def emit(component: ConvertedLibrary) -> None: + name = component.get_require_name() + if name in by_name: + raise EsphomeError( + f"Two libraries resolve to the same Zephyr module '{name}' " + f"({by_name[name]} and {component.path}). Reference the library " + f"consistently (e.g. always as 'owner/name') so it resolves once." + ) + by_name[name] = component.path + _emit_zephyr_module(component) + module_dirs.append(component.path) + + backend = LibraryBackend( + platform=None, framework=ZEPHYR_FRAMEWORK, emit=emit, cache_key="zephyr" + ) + convert_libraries(libraries, backend) + return module_dirs diff --git a/esphome/components/zephyr_ble_server/ble_server.h b/esphome/components/zephyr_ble_server/ble_server.h index bf69c52b12..223dbf7ac9 100644 --- a/esphome/components/zephyr_ble_server/ble_server.h +++ b/esphome/components/zephyr_ble_server/ble_server.h @@ -6,7 +6,7 @@ namespace esphome::zephyr_ble_server { -class BLEServer : public Component { +class BLEServer final : public Component { public: void setup() override; void dump_config() override; @@ -21,7 +21,7 @@ class BLEServer : public Component { CallbackManager passkey_cb_; }; -template class BLENumericComparisonReplyAction : public Action { +template class BLENumericComparisonReplyAction final : public Action { public: explicit BLENumericComparisonReplyAction(BLEServer *parent) : parent_(parent) {} diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index b0d86190b8..0ff1825bd1 100644 --- a/esphome/components/zephyr_mcumgr/ota/__init__.py +++ b/esphome/components/zephyr_mcumgr/ota/__init__.py @@ -6,9 +6,19 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_data, ) -from esphome.components.zephyr.const import BOOTLOADER_MCUBOOT, KEY_BOOTLOADER +from esphome.components.zephyr.const import ( + BOOTLOADER_MCUBOOT, + KEY_BOOTLOADER, + KEY_SYSBUILD, +) import esphome.config_validation as cv -from esphome.const import CONF_HARDWARE_UART, CONF_ID, Framework +from esphome.const import ( + CONF_HARDWARE_UART, + CONF_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + Framework, +) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.types import ConfigType @@ -139,3 +149,6 @@ async def to_code(config: ConfigType) -> None: }}; """ ) + framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] + if framework_ver >= cv.Version(2, 9, 2): + zephyr_data()[KEY_SYSBUILD] = True diff --git a/esphome/components/zhlt01/zhlt01.h b/esphome/components/zhlt01/zhlt01.h index 61fc2cc16a..dba9ca8f3d 100644 --- a/esphome/components/zhlt01/zhlt01.h +++ b/esphome/components/zhlt01/zhlt01.h @@ -142,7 +142,7 @@ static const float AC1_TEMP_MIN = 16.0f; static const float AC1_TEMP_MAX = 32.0f; static const float AC1_TEMP_INC = 1.0f; -class ZHLT01Climate : public climate_ir::ClimateIR { +class ZHLT01Climate final : public climate_ir::ClimateIR { public: ZHLT01Climate() : climate_ir::ClimateIR( diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index c75b0773d2..775fb35140 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -8,6 +8,9 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME @@ -15,10 +18,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType from .const import ( + CONF_ENDPOINT, + CONF_MAX_EP_NUMBER, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -28,7 +34,7 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_MAX_EP_NUMBER, + CONF_MAX_EP_NUMBER_ZEPHYR, CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, @@ -52,13 +58,33 @@ CODEOWNERS = ["@luar123", "@tomaszduda23"] CONFLICTS_WITH = ["openthread"] + +def _check_report_deprecation(value: str) -> str: + if str(value).lower() in ("coordinator", "enable"): + _LOGGER.warning( + "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + ) + return value + + BASE_SCHEMA = cv.Schema( { cv.Optional(CONF_REPORT): cv.All( cv.requires_component("zigbee"), cv.requires_component("esp32"), + _check_report_deprecation, cv.enum(REPORT, lower=True), - ) + ), + cv.Optional(CONF_ENDPOINT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.int_range(1, CONF_MAX_EP_NUMBER), + ), + cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.boolean, + ), } ) BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) @@ -111,7 +137,10 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp32, only_on_variant( supported=[ + VARIANT_ESP32S31, VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, VARIANT_ESP32C5, VARIANT_ESP32C6, ] @@ -132,8 +161,8 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: _LOGGER.warning( "Single endpoint requires ZHA or at leatst Zigbee2MQTT 2.8.0. For older versions of Zigbee2MQTT use multiple endpoints" ) - if count > CONF_MAX_EP_NUMBER and not CORE.testing_mode: - raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER}") + if count > CONF_MAX_EP_NUMBER_ZEPHYR and not CORE.testing_mode: + raise cv.Invalid(f"Maximum number of end points is {CONF_MAX_EP_NUMBER_ZEPHYR}") return config diff --git a/esphome/components/zigbee/automation.h b/esphome/components/zigbee/automation.h index 55ee9746ea..1f953100d9 100644 --- a/esphome/components/zigbee/automation.h +++ b/esphome/components/zigbee/automation.h @@ -9,7 +9,7 @@ #endif namespace esphome::zigbee { -template class FactoryResetAction : public Action, public Parented { +template class FactoryResetAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->factory_reset(); } }; diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 7d0e14c67a..cfd23b9eb2 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -55,21 +55,25 @@ REPORT = { "coordinator": report.ZIGBEE_REPORT_COORDINATOR, "enable": report.ZIGBEE_REPORT_ENABLE, "force": report.ZIGBEE_REPORT_FORCE, + "default": report.ZIGBEE_REPORT_DEFAULT, } +CONF_ENDPOINT = "endpoint" +CONF_MAX_EP_NUMBER = 239 CONF_ON_JOIN = "on_join" CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { - "UNKNOWN": "ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN", - "MAINS_SINGLE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE", - "MAINS_THREE_PHASE": "ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE", - "BATTERY": "ZB_ZCL_BASIC_POWER_SOURCE_BATTERY", - "DC_SOURCE": "ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE", - "EMERGENCY_MAINS_CONST": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST", - "EMERGENCY_MAINS_TRANSF": "ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF", + "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN + "MAINS_SINGLE_PHASE": 0x01, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_SINGLE_PHASE + "MAINS_THREE_PHASE": 0x02, # ZB_ZCL_BASIC_POWER_SOURCE_MAINS_THREE_PHASE + "BATTERY": 0x03, # ZB_ZCL_BASIC_POWER_SOURCE_BATTERY + "DC_SOURCE": 0x04, # ZB_ZCL_BASIC_POWER_SOURCE_DC_SOURCE + "EMERGENCY_MAINS_CONST": 0x05, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_CONST + "EMERGENCY_MAINS_TRANSF": 0x06, # ZB_ZCL_BASIC_POWER_SOURCE_EMERGENCY_MAINS_TRANSF } KEY_ZIGBEE = "zigbee" diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index bb507320eb..bfc4d93d5b 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -2,38 +2,33 @@ import esphome.codegen as cg DEVICE_TYPE = "device_type" ROLE = "role" -CONF_MAX_EP_NUMBER = 239 -CONF_NUM = "num" CONF_CLUSTERS = "clusters" CONF_ATTRIBUTES = "attributes" -CONF_ENDPOINT = "endpoint" CONF_CLUSTER = "cluster" SCALE = "scale" CONF_ATTRIBUTE_ID = "attribute_id" -KEY_BS_EP = "binary_sensor_ep" -KEY_SENSOR_EP = "sensor_ep" +KEY_ZIGBEE_EP = "zigbee_ep" +KEY_ZIGBEE_EP_NO_NUM = "zigbee_ep_no_num" -ha_standard_devices = cg.esphome_ns.enum("zb_ha_standard_devs_e") DEVICE_ID = { - "RANGE_EXTENDER": ha_standard_devices.ZB_HA_RANGE_EXTENDER_DEVICE_ID, - "SIMPLE_SENSOR": ha_standard_devices.ZB_HA_SIMPLE_SENSOR_DEVICE_ID, - "CUSTOM_ATTR": ha_standard_devices.ZB_HA_CUSTOM_ATTR_DEVICE_ID, + "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), + "SIMPLE_SENSOR": cg.RawExpression("EZB_ZHA_SIMPLE_SENSOR_DEVICE_ID"), + "CUSTOM_ATTR": 0xFFF2, } -cluster_id = cg.esphome_ns.enum("esp_zb_zcl_cluster_id_t") +cluster_id = cg.esphome_ns.enum("ezb_zcl_cluster_id_e") CLUSTER_ID = { - "BASIC": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BASIC, - "BINARY_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT, - "ANALOG_INPUT": cluster_id.ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, + "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, } -cluster_role = cg.esphome_ns.enum("esp_zb_zcl_cluster_role_t") CLUSTER_ROLE = { - "SERVER": cluster_role.ESP_ZB_ZCL_CLUSTER_SERVER_ROLE, + "SERVER": cg.RawExpression("EZB_ZCL_CLUSTER_SERVER"), } -attr_type = cg.esphome_ns.enum("esp_zb_zcl_attr_type_t") +attr_type = cg.esphome_ns.enum("ezb_zcl_attr_type_e") ATTR_TYPE = { - "BOOL": attr_type.ESP_ZB_ZCL_ATTR_TYPE_BOOL, - "8BITMAP": attr_type.ESP_ZB_ZCL_ATTR_TYPE_8BITMAP, - "CHAR_STRING": attr_type.ESP_ZB_ZCL_ATTR_TYPE_CHAR_STRING, - "SINGLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_SINGLE, - "DOUBLE": attr_type.ESP_ZB_ZCL_ATTR_TYPE_DOUBLE, + "BOOL": attr_type.EZB_ZCL_ATTR_TYPE_BOOL, + "MAP8": attr_type.EZB_ZCL_ATTR_TYPE_MAP8, + "STRING": attr_type.EZB_ZCL_ATTR_TYPE_STRING, + "SINGLE": attr_type.EZB_ZCL_ATTR_TYPE_SINGLE, + "DOUBLE": attr_type.EZB_ZCL_ATTR_TYPE_DOUBLE, } diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index 63d03c7952..bf8e8287c4 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -1,4 +1,4 @@ -CONF_MAX_EP_NUMBER = 8 +CONF_MAX_EP_NUMBER_ZEPHYR = 8 CONF_ZIGBEE_ID = "zigbee_id" CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.h b/esphome/components/zigbee/time/zigbee_time_zephyr.h index 3c2adc4b5f..be2cff786e 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.h +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.h @@ -12,7 +12,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeTime : public time::RealTimeClock, public ZigbeeEntity { +class ZigbeeTime final : public time::RealTimeClock, public ZigbeeEntity { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 0a06792c59..d7176e6ca5 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -12,63 +12,49 @@ void ZigbeeAttribute::set_attr_() { if (!this->zb_->is_connected()) { return; } - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_status_t state = esp_zb_zcl_set_attribute_val(this->endpoint_id_, this->cluster_id_, this->role_, - this->attr_id_, this->value_p_, false); + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_status_t state = ezb_zcl_set_attr_value(this->endpoint_id_, this->cluster_id_, this->role_, this->attr_id_, + EZB_ZCL_STD_MANUF_CODE, this->value_p_, false); if (this->force_report_) { this->report_(true); } this->set_attr_requested_ = false; // Check for error - if (state != ESP_ZB_ZCL_STATUS_SUCCESS) { + if (state != EZB_ZCL_STATUS_SUCCESS) { ESP_LOGE(TAG, "Setting attribute failed, ZCL status: %u", static_cast(state)); } - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } void ZigbeeAttribute::report_(bool has_lock) { - if (!this->zb_->is_connected()) { + if (!this->zb_->is_connected() || !this->report_enabled) { return; } - if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { - esp_zb_zcl_report_attr_cmd_t cmd = {}; - cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT; - cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI; - cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000; - cmd.zcl_basic_cmd.dst_endpoint = 1; - cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_; - cmd.clusterID = this->cluster_id_; - cmd.attributeID = this->attr_id_; + if (has_lock or esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + ezb_zcl_report_attr_cmd_t cmd = {}; + cmd.cmd_ctrl.fc.direction = EZB_ZCL_CMD_DIRECTION_TO_CLI; + cmd.cmd_ctrl.fc.dis_default_rsp = 1; + cmd.cmd_ctrl.dst_addr.addr_mode = EZB_ADDR_MODE_SHORT; + cmd.cmd_ctrl.dst_addr.u.short_addr = 0x0000; + cmd.cmd_ctrl.dst_ep = 1; + cmd.cmd_ctrl.src_ep = this->endpoint_id_; + cmd.cmd_ctrl.cluster_id = this->cluster_id_; + cmd.cmd_ctrl.fc.manuf_specific = 0; + cmd.payload.attr_id = this->attr_id_; - esp_zb_zcl_report_attr_cmd_req(&cmd); + ezb_zcl_report_attr_cmd_req(&cmd); if (!has_lock) { - esp_zb_lock_release(); + esp_zigbee_lock_release(); } } } -esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() { - esp_zb_zcl_reporting_info_t reporting_info = {}; - reporting_info.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV; - reporting_info.ep = this->endpoint_id_; - reporting_info.cluster_id = this->cluster_id_; - reporting_info.cluster_role = this->role_; - reporting_info.attr_id = this->attr_id_; - reporting_info.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC; - reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID; - reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */ - reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */ - reporting_info.u.send_info.def_min_interval = 10; /*!< Default minimum reporting interval */ - reporting_info.u.send_info.def_max_interval = 0; /*!< Default maximum reporting interval */ - reporting_info.u.send_info.delta.s16 = 0; /*!< Actual reportable change */ - - return reporting_info; -} - -void ZigbeeAttribute::set_report(bool force) { +void ZigbeeAttribute::set_report(ZigbeeReportT report) { this->report_enabled = true; - this->force_report_ = force; + if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { + this->force_report_ = true; + } } void ZigbeeAttribute::loop() { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 35aa60848f..e5f8c8b1cf 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -9,7 +9,7 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" #include "zigbee_esp32.h" #ifdef USE_SENSOR @@ -22,12 +22,13 @@ namespace esphome::zigbee { enum ZigbeeReportT { + ZIGBEE_REPORT_DEFAULT, ZIGBEE_REPORT_COORDINATOR, ZIGBEE_REPORT_ENABLE, ZIGBEE_REPORT_FORCE, }; -class ZigbeeAttribute : public Component { +class ZigbeeAttribute final : public Component { public: ZigbeeAttribute(ZigbeeComponent *parent, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t attr_type, float scale, uint8_t max_size) @@ -41,10 +42,9 @@ class ZigbeeAttribute : public Component { scale_(scale) {} void loop() override; template void add_attr(T value); - esp_zb_zcl_reporting_info_t get_reporting_info(); template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } - void set_report(bool force); + void set_report(ZigbeeReportT report); #ifdef USE_SENSOR template void connect(sensor::Sensor *sensor); #endif diff --git a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h index aae79fa289..bc2718ff48 100644 --- a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.h @@ -28,7 +28,7 @@ extern "C" { namespace esphome::zigbee { -class ZigbeeBinarySensor : public ZigbeeEntity, public Component { +class ZigbeeBinarySensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor); void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 5dd76e9903..2ed3dddb67 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -2,16 +2,22 @@ from typing import Any import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_ID, CONF_TYPE +from esphome.core import CORE -from .const import CONF_REPORT, REPORT +from .const import ( + CONF_MAX_EP_NUMBER, + CONF_REPORT, + CONF_USE_DEVICE_TYPE, + KEY_ZIGBEE, + REPORT, +) from .const_esp32 import ( - CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_MAX_EP_NUMBER, - CONF_NUM, DEVICE_TYPE, + KEY_ZIGBEE_EP, + KEY_ZIGBEE_EP_NO_NUM, ROLE, ) @@ -22,12 +28,12 @@ ep_configs: dict[str, dict[str, Any]] = { CONF_CLUSTERS: [ { CONF_ID: "BINARY_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -36,27 +42,26 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, ], }, "analog_input": { - DEVICE_TYPE: "CUSTOM_ATTR", CONF_CLUSTERS: [ { CONF_ID: "ANALOG_INPUT", - ROLE: CLUSTER_ROLE["SERVER"], + ROLE: "SERVER", CONF_ATTRIBUTES: [ { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: REPORT["enable"], + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_DEVICE: None, }, { @@ -65,11 +70,11 @@ ep_configs: dict[str, dict[str, Any]] = { }, { CONF_ATTRIBUTE_ID: 0x6F, - CONF_TYPE: "8BITMAP", + CONF_TYPE: "MAP8", }, { CONF_ATTRIBUTE_ID: 0x1C, - CONF_TYPE: "CHAR_STRING", + CONF_TYPE: "STRING", }, ], }, @@ -78,22 +83,144 @@ ep_configs: dict[str, dict[str, Any]] = { } -def create_ep(ep_list: list[dict[str, Any]], router: bool) -> list[dict[str, Any]]: +def get_next_ep_num(eps: list[int]) -> int: + try: + ep_num = [i for i in range(1, CONF_MAX_EP_NUMBER + 1) if i not in eps][0] + eps.append(ep_num) + except IndexError as e: + raise cv.Invalid( + f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." + ) from e + return ep_num + + +def compare_clusters( + existing_ep: dict[str, Any], + ep: dict[str, Any], +) -> tuple[str | int, str] | None: + existing_clusters = [(cl[CONF_ID], cl[ROLE]) for cl in existing_ep[CONF_CLUSTERS]] + for cl in [(cl[CONF_ID], cl[ROLE]) for cl in ep[CONF_CLUSTERS]]: + if cl in existing_clusters: + return cl + return None + + +def merge_endpoints( + existing_ep: dict[str, Any], + ep: dict[str, Any], + use_type: bool | None, +) -> bool: + if compare_clusters(existing_ep, ep): + return False + if ( + ep.get(DEVICE_TYPE) + and existing_ep.get(DEVICE_TYPE) + and ep.get(DEVICE_TYPE) != existing_ep.get(DEVICE_TYPE) + ): + return False + if ( + ep.get(DEVICE_TYPE) + and not existing_ep.get(DEVICE_TYPE) + and existing_ep.get(CONF_USE_DEVICE_TYPE) + ): + return False + if existing_ep.get(DEVICE_TYPE) and not ep.get(DEVICE_TYPE) and use_type: + return False + if use_type: + existing_ep[CONF_USE_DEVICE_TYPE] = use_type + if ep.get(DEVICE_TYPE): + existing_ep[DEVICE_TYPE] = ep[DEVICE_TYPE] + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + return True + + +def validate_endpoints(ep_dict: dict[int, dict]) -> None: + for num, ep in ep_dict.items(): + types_dict = ep.get(CONF_USE_DEVICE_TYPE) + if not types_dict: + continue + if len(types_dict) == 1: + ep[DEVICE_TYPE] = list(types_dict.keys())[0] + del ep[CONF_USE_DEVICE_TYPE] + continue + types_list = [t[0] for t in types_dict.items() if t[1]] + if len(types_list) > 1: + raise cv.Invalid( + f"There is more than one component with endpoint: {num} and {CONF_USE_DEVICE_TYPE}: True" + ) + if not types_list: + raise cv.Invalid( + f"Multiple device types on endpoint: {num}. Set {CONF_USE_DEVICE_TYPE}: True on one component." + ) + ep[DEVICE_TYPE] = types_list[0] + del ep[CONF_USE_DEVICE_TYPE] + + +def create_ep(router: bool) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + validate_endpoints(ep_dict) # create dummy endpoint if list is empty - if not ep_list: + if not ep_dict and not ep_list: ep_type = "CUSTOM_ATTR" if router: ep_type = "RANGE_EXTENDER" - ep_list = [ - { - DEVICE_TYPE: ep_type, - } - ] - # enumerate endpoints - for i, ep in enumerate(ep_list, 1): - ep[CONF_NUM] = i - if len(ep_list) > CONF_MAX_EP_NUMBER: - raise cv.Invalid( - f"Too many devices. Zigbee can define only {CONF_MAX_EP_NUMBER} endpoints." - ) - return ep_list + ep_dict[1] = {DEVICE_TYPE: ep_type} + if ep_list: + # merge endpoint with different clusters + ep_list_new: list[dict] = [] + for ep in ep_list: + added = False + for existing_ep in ep_list_new: + if merge_endpoints(existing_ep, ep, ep.get(CONF_USE_DEVICE_TYPE)): + added = True + break + if not added: + ep_list_new.append(ep) + + # Add endpoints with no number to the endpoint dict with a new number + eps = list(ep_dict.keys()) + for ep in ep_list_new: + ep_num = get_next_ep_num(eps) + ep_dict[ep_num] = ep + + # clear list so that it is not processed again + del zb_data[KEY_ZIGBEE_EP_NO_NUM] + + # Add default device type to endpoints that have none + for ep in ep_dict.values(): + if not ep.get(DEVICE_TYPE): + ep[DEVICE_TYPE] = "CUSTOM_ATTR" + + +def add_ep(ep: dict[str, Any], ep_num: int | None, use_type: bool | None) -> None: + zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) + if use_type is False: + ep.pop(DEVICE_TYPE, None) + if ep_num is None: + if use_type: + ep[CONF_USE_DEVICE_TYPE] = use_type + ep_list: list[dict] = zb_data.setdefault(KEY_ZIGBEE_EP_NO_NUM, []) + ep_list.append(ep) + else: + ep_dict: dict[int, dict] = zb_data.setdefault(KEY_ZIGBEE_EP, {}) + if ep_num in ep_dict: + # check if the existing endpoint has same clusters + existing_ep = ep_dict[ep_num] + if cl := compare_clusters( + existing_ep, + ep, + ): + raise cv.Invalid( + f"Endpoint {ep_num} has more than one cluster with cluster id {cl[0]} and role {cl[1]}." + ) + if ep.get(DEVICE_TYPE) or use_type: + types_dict = existing_ep.setdefault(CONF_USE_DEVICE_TYPE, {}) + if not types_dict.get(ep.get(DEVICE_TYPE)) or use_type: + types_dict[ep.get(DEVICE_TYPE)] = use_type + existing_ep[CONF_CLUSTERS].extend(ep[CONF_CLUSTERS]) + else: + if use_type or ep.get(DEVICE_TYPE): + ep[CONF_USE_DEVICE_TYPE] = {ep.get(DEVICE_TYPE): use_type} + ep_dict[ep_num] = ep diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 1809f181be..3e0f6cd745 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -36,121 +36,139 @@ uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size) { return zcl_str; } -static void bdb_start_top_level_commissioning_cb(uint8_t mode_mask) { - if (esp_zb_bdb_start_top_level_commissioning(mode_mask) != ESP_OK) { - ESP_LOGE(TAG, "Start network steering failed!"); +void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { + if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { + global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + return; } + if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Start top level commissioning failed!"); + } + esp_zigbee_lock_release(); } -extern "C" void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) { +bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { static uint8_t steering_retry_count = 0; - uint32_t *p_sg_p = signal_struct->p_app_signal; - esp_err_t err_status = signal_struct->esp_err_status; - esp_zb_app_signal_type_t sig_type = (esp_zb_app_signal_type_t) *p_sg_p; - esp_zb_zdo_signal_leave_params_t *leave_params = NULL; - switch (sig_type) { - case ESP_ZB_ZDO_SIGNAL_SKIP_STARTUP: + ezb_app_signal_type_t signal_type = ezb_app_signal_get_type(app_signal); + switch (signal_type) { + case EZB_ZDO_SIGNAL_SKIP_STARTUP: ESP_LOGD(TAG, "Zigbee stack initialized"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_INITIALIZATION); + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_INITIALIZATION); break; - case ESP_ZB_BDB_SIGNAL_DEVICE_FIRST_START: - case ESP_ZB_BDB_SIGNAL_DEVICE_REBOOT: - if (err_status == ESP_OK) { - ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", esp_zb_bdb_is_factory_new() ? "" : "non "); + case EZB_BDB_SIGNAL_DEVICE_FIRST_START: + case EZB_BDB_SIGNAL_DEVICE_REBOOT: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { + ESP_LOGD(TAG, "Device started up in %sfactory-reset mode", ezb_bdb_is_factory_new() ? "" : "non "); global_zigbee->started = true; - if (esp_zb_bdb_is_factory_new()) { + if (ezb_bdb_is_factory_new()) { global_zigbee->factory_new = true; ESP_LOGD(TAG, "Start network steering"); - esp_zb_bdb_start_top_level_commissioning(ESP_ZB_BDB_MODE_NETWORK_STEERING); + ezb_bdb_start_top_level_commissioning(EZB_BDB_MODE_NETWORK_STEERING); } else { ESP_LOGD(TAG, "Device rebooted"); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } } else { - ESP_LOGE(TAG, "FIRST_START. Device started up in %sfactory-reset mode with an error %d (%s)", - esp_zb_bdb_is_factory_new() ? "" : "non ", err_status, esp_err_to_name(err_status)); - ESP_LOGW(TAG, "Failed to initialize Zigbee stack (status: %s)", esp_err_to_name(err_status)); - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, ESP_ZB_BDB_MODE_INITIALIZATION, - 1000); + ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); + }); } - break; - case ESP_ZB_BDB_SIGNAL_STEERING: - if (err_status == ESP_OK) { + } break; + case EZB_BDB_SIGNAL_STEERING: { + ezb_bdb_comm_status_t status = *((ezb_bdb_comm_status_t *) ezb_app_signal_get_params(app_signal)); + if (status == EZB_BDB_STATUS_SUCCESS) { steering_retry_count = 0; - ESP_LOGI(TAG, "Joined network successfully (PAN ID: 0x%04hx, Channel:%d)", esp_zb_get_pan_id(), - esp_zb_get_current_channel()); + ezb_extpanid_t extended_pan_id; + ezb_nwk_get_extended_panid(&extended_pan_id); + ESP_LOGD(TAG, "Joined network successfully: PAN ID(0x%04hx, EXT: 0x%llx), Channel(%d), Short Address(0x%04hx)", + ezb_nwk_get_panid(), extended_pan_id.u64, ezb_nwk_get_current_channel(), ezb_nwk_get_short_address()); global_zigbee->joined = true; global_zigbee->enable_loop_soon_any_context(); } else { - ESP_LOGI(TAG, "Network steering was not successful (status: %s)", esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 1000); + global_zigbee->set_timeout("zb_init", 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } else { - esp_zb_scheduler_alarm((esp_zb_callback_t) bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_NETWORK_STEERING, 600 * 1000); + global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); + }); } } - break; - case ESP_ZB_ZDO_SIGNAL_LEAVE: - leave_params = (esp_zb_zdo_signal_leave_params_t *) esp_zb_app_signal_get_params(p_sg_p); - if (leave_params->leave_type == ESP_ZB_NWK_LEAVE_TYPE_RESET) { - esp_zb_factory_reset(); + } break; + case EZB_ZDO_SIGNAL_LEAVE: { + const ezb_zdo_signal_leave_params_t *leave_params = + (const ezb_zdo_signal_leave_params_t *) ezb_app_signal_get_params(app_signal); + if (leave_params->leave_type == EZB_ZDO_LEAVE_TYPE_RESET) { + esp_zigbee_factory_reset(); } - break; + } break; default: - ESP_LOGD(TAG, "ZDO signal: %s (0x%x), status: %s", esp_zb_zdo_signal_to_string(sig_type), sig_type, - esp_err_to_name(err_status)); + ESP_LOGD(TAG, "Zigbee APP Signal: %s(type: 0x%02x)", ezb_app_signal_to_string(signal_type), signal_type); break; } + return true; } -static esp_err_t zb_attribute_handler(const esp_zb_zcl_set_attr_value_message_t *message) { - esp_err_t ret = ESP_OK; - ESP_RETURN_ON_FALSE(message, ESP_FAIL, TAG, "Empty message"); - ESP_RETURN_ON_FALSE(message->info.status == ESP_ZB_ZCL_STATUS_SUCCESS, ESP_ERR_INVALID_ARG, TAG, - "Received message: error status(%d)", message->info.status); - ESP_LOGD(TAG, "Received message: endpoint(%d), cluster(0x%x), attribute(0x%x), data size(%d)", - message->info.dst_endpoint, message->info.cluster, message->attribute.id, message->attribute.data.size); - return ret; +static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { + ESP_RETURN_ON_FALSE(message, , TAG, "Empty message"); + ESP_RETURN_ON_FALSE(message->info.status == EZB_ZCL_STATUS_SUCCESS, , TAG, "Received message: error status(%d)", + message->info.status); + ESP_LOGD(TAG, "ZCL SetAttributeValue message for endpoint(%d) cluster(0x%04x) %s with status(0x%02x)", + message->info.dst_ep, message->info.cluster_id, + message->info.cluster_role == EZB_ZCL_CLUSTER_SERVER ? "server" : "client", message->info.status); } -static esp_err_t zb_action_handler(esp_zb_core_action_callback_id_t callback_id, const void *message) { - esp_err_t ret = ESP_OK; +static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, void *message) { switch (callback_id) { - case ESP_ZB_CORE_SET_ATTR_VALUE_CB_ID: - ret = zb_attribute_handler((esp_zb_zcl_set_attr_value_message_t *) message); + case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: + zb_attribute_handler((ezb_zcl_set_attr_value_message_t *) message); break; + case EZB_ZCL_CORE_DEFAULT_RSP_CB_ID: { +#ifdef ESPHOME_LOG_HAS_VERBOSE + ezb_zcl_cmd_default_rsp_message_t *default_rsp = (ezb_zcl_cmd_default_rsp_message_t *) message; + ESP_LOGV(TAG, "Received ZCL Default Response: 0x%02x", default_rsp->in.status_code); +#endif + } break; default: - ESP_LOGD(TAG, "Receive Zigbee action(0x%x) callback", callback_id); + ESP_LOGD(TAG, "Receive Zigbee action(0x%04x) callback", static_cast(callback_id)); break; } - return ret; } -void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id) { - esp_zb_cluster_list_t *cluster_list = esp_zb_zcl_cluster_list_create(); - this->endpoint_list_[endpoint_id] = - std::tuple(device_id, cluster_list); - // Add basic cluster - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_BASIC, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); - // Add identify cluster if not already present - if (esp_zb_cluster_list_get_cluster(cluster_list, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE) == - nullptr) { - this->add_cluster(endpoint_id, ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY, ESP_ZB_ZCL_CLUSTER_SERVER_ROLE); +void ZigbeeComponent::create_default_cluster(uint8_t endpoint_id, uint16_t device_id) { + ezb_af_ep_config_t config = { + .ep_id = endpoint_id, + .app_profile_id = EZB_AF_HA_PROFILE_ID, + .app_device_id = device_id, + .app_device_version = 0, + }; + ezb_af_ep_desc_t ep_desc = ezb_af_create_endpoint_desc(&config); + if (ezb_af_device_add_endpoint_desc(this->dev_desc_, ep_desc) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not create endpoint %u", endpoint_id); } + // Add basic cluster + this->update_basic_cluster_(ep_desc); + // Add identify cluster if not already present + this->add_cluster(endpoint_id, EZB_ZCL_CLUSTER_ID_IDENTIFY, EZB_ZCL_CLUSTER_SERVER); } void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role) { - esp_zb_attribute_list_t *attr_list; - if (cluster_id == 0) { - attr_list = create_basic_cluster_(); - } else { - attr_list = esphome_zb_default_attr_list_create(cluster_id); + if (cluster_id == EZB_ZCL_CLUSTER_ID_BASIC) { + return; } - this->attribute_list_[{endpoint_id, cluster_id, role}] = attr_list; + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + ESP_LOGE(TAG, "Endpoint %u does not exist, cannot add cluster 0x%04X", endpoint_id, cluster_id); + return; + } + esphome_zb_add_or_update_cluster(cluster_id, ep_desc, role); + ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { @@ -166,131 +184,121 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu }; } -esp_zb_attribute_list_t *ZigbeeComponent::create_basic_cluster_() { - esp_zb_basic_cluster_cfg_t basic_cluster_cfg = { - .zcl_version = ESP_ZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, - .power_source = this->basic_cluster_data_.power_source, - }; - esp_zb_attribute_list_t *attr_list = esp_zb_basic_cluster_create(&basic_cluster_cfg); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, - this->basic_cluster_data_.manufacturer); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); - esp_zb_basic_cluster_add_attr(attr_list, ESP_ZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); - return attr_list; +void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { + ezb_zcl_cluster_desc_t cluster_desc = + ezb_af_endpoint_get_cluster_desc(ep_desc, EZB_ZCL_CLUSTER_ID_BASIC, EZB_ZCL_CLUSTER_SERVER); + if (cluster_desc == NULL) { + ezb_zcl_basic_cluster_config_t basic_cluster_cfg = { + .zcl_version = EZB_ZCL_BASIC_ZCL_VERSION_DEFAULT_VALUE, + .power_source = this->basic_cluster_data_.power_source, + }; + cluster_desc = ezb_zcl_basic_create_cluster_desc(&basic_cluster_cfg, EZB_ZCL_CLUSTER_SERVER); + } + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MANUFACTURER_NAME_ID, + this->basic_cluster_data_.manufacturer); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, + this->basic_cluster_data_.model); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); } -esp_err_t ZigbeeComponent::create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list) { - esp_zb_endpoint_config_t endpoint_config = {.endpoint = endpoint_id, - .app_profile_id = ESP_ZB_AF_HA_PROFILE_ID, - .app_device_id = static_cast(device_id), - .app_device_version = 0}; - return esp_zb_ep_list_add_ep(this->esp_zb_ep_list_, esp_zb_cluster_list, endpoint_config); +bool ZigbeeComponent::register_device() { + if (ezb_af_device_desc_register(this->dev_desc_) != EZB_ERR_NONE) { + ESP_LOGE(TAG, "Could not register the endpoint list"); + this->mark_failed(); + return false; + } + return true; } -static void esp_zb_task(void *pv_parameters) { - if (esp_zb_start(false) != ESP_OK) { - ESP_LOGE(TAG, "Could not setup Zigbee"); +static void ezb_task(void *pv_parameters) { + if (!global_zigbee->register_device()) { vTaskDelete(NULL); + return; } - if (global_zigbee->is_battery_powered()) { - ESP_LOGD(TAG, "Battery powered!"); - esp_zb_set_node_descriptor_power_source(false); - } else { - esp_zb_set_node_descriptor_power_source(true); + if (esp_zigbee_start(false) != ESP_OK) { + ESP_LOGE(TAG, "Could not setup Zigbee"); + global_zigbee->mark_failed(); + vTaskDelete(NULL); + return; // vTaskDelete(NULL) never returns, but keep intent explicit } - esp_zb_stack_main_loop(); + + // Increase priority to 5 to align with openthread or BLE + vTaskPrioritySet(NULL, 5); + + esp_zigbee_launch_mainloop(); + + esp_zigbee_deinit(); + + vTaskDelete(NULL); +} + +ZigbeeComponent::ZigbeeComponent() { + esp_zigbee_platform_config_t platform_config = { + .storage_partition_name = "nvs", + .radio_config = EZB_DEFAULT_RADIO_CONFIG(), + }; + esp_zigbee_device_config_t device_config = { + .device_type = this->device_role_, + .install_code_policy = false, + }; +#ifdef CONFIG_ZB_ZCZR + esp_zigbee_zczr_config_s zb_zczr_cfg = { + .max_children = MAX_CHILDREN, + }; + device_config.zczr_config = zb_zczr_cfg; +#else + esp_zigbee_zed_config_s zb_zed_cfg = { + .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, + .keep_alive = ED_KEEP_ALIVE, + }; + device_config.zed_config = zb_zed_cfg; +#endif + esp_zigbee_config_t config = {.device_config = device_config, .platform_config = platform_config}; + if (esp_zigbee_init(&config) != ESP_OK) { + ESP_LOGE(TAG, "Could not initialize Zigbee"); + this->mark_failed(); + return; + } + this->dev_desc_ = ezb_af_create_device_desc(); } void ZigbeeComponent::setup() { global_zigbee = this; - esp_zb_platform_config_t config = {}; - config.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(); - config.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(); #ifdef USE_WIFI if (esp_coex_wifi_i154_enable() != ESP_OK) { this->mark_failed(); return; } #endif - if (esp_zb_platform_config(&config) != ESP_OK) { + ezb_aps_secur_enable_distributed_security(false); + ezb_nwk_set_min_join_lqi(32); + if (ezb_app_signal_add_handler(ZigbeeComponent::app_signal_handler) != ESP_OK) { + ESP_LOGE(TAG, "Could not set application signal handler"); this->mark_failed(); return; } - esp_zb_cfg_t zb_nwk_cfg = { - .esp_zb_role = this->device_role_, - .install_code_policy = false, - }; -#ifdef ZB_ROUTER_ROLE - esp_zb_zczr_cfg_t zb_zczr_cfg = { - .max_children = MAX_CHILDREN, - }; - zb_nwk_cfg.nwk_cfg.zczr_cfg = zb_zczr_cfg; -#else - esp_zb_zed_cfg_t zb_zed_cfg = { - .ed_timeout = ESP_ZB_ED_AGING_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, - }; - zb_nwk_cfg.nwk_cfg.zed_cfg = zb_zed_cfg; -#endif - esp_zb_init(&zb_nwk_cfg); + ezb_zcl_core_action_handler_register(zb_action_handler); - esp_err_t ret; - for (auto const &[key, val] : this->attribute_list_) { - esp_zb_cluster_list_t *esp_zb_cluster_list = std::get<1>(this->endpoint_list_[std::get<0>(key)]); - ret = esphome_zb_cluster_list_add_or_update_cluster(std::get<1>(key), esp_zb_cluster_list, val, std::get<2>(key)); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Could not create cluster 0x%04X with role %u: %s", std::get<1>(key), std::get<2>(key), - esp_err_to_name(ret)); - } else { - ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", std::get<0>(key), std::get<1>(key), - std::get<2>(key)); -#ifdef ESPHOME_LOG_HAS_VERBOSE - // Dump cluster attributes in verbose log - ESP_LOGV(TAG, "Cluster 0x%04X attributes:", std::get<1>(key)); - esp_zb_attribute_list_t *attr_list = val; - while (attr_list) { - esp_zb_zcl_attr_t *attr = &attr_list->attribute; - ESP_LOGV(TAG, " Attr ID: 0x%04X, Type: 0x%02X, Access: 0x%02X", attr->id, attr->type, attr->access); - attr_list = attr_list->next; - } -#endif - } - } - this->attribute_list_.clear(); - - for (auto const &[ep_id, dev_id] : this->endpoint_list_) { - if (create_endpoint(ep_id, std::get<0>(dev_id), std::get<1>(dev_id)) != ESP_OK) { - ESP_LOGE(TAG, "Could not create endpoint %u", ep_id); - } - } - this->endpoint_list_.clear(); - - if (esp_zb_device_register(this->esp_zb_ep_list_) != ESP_OK) { - ESP_LOGE(TAG, "Could not register the endpoint list"); - this->mark_failed(); - return; - } - - esp_zb_core_action_handler_register(zb_action_handler); - - if (esp_zb_set_primary_network_channel_set(ESP_ZB_TRANSCEIVER_ALL_CHANNELS_MASK) != ESP_OK) { + if (ezb_bdb_set_primary_channel_set(EZB_PRIMARY_CHANNEL_MASK) != ESP_OK) { ESP_LOGE(TAG, "Could not setup Zigbee"); this->mark_failed(); return; } - for (auto &[_, attribute] : this->attributes_) { - if (attribute->report_enabled) { - esp_zb_zcl_reporting_info_t reporting_info = attribute->get_reporting_info(); - ESP_LOGD(TAG, "set reporting for cluster: %u", reporting_info.cluster_id); - if (esp_zb_zcl_update_reporting_info(&reporting_info) != ESP_OK) { - ESP_LOGE(TAG, "Could not configure reporting for attribute 0x%04X in cluster 0x%04X in endpoint %u", - reporting_info.attr_id, reporting_info.cluster_id, reporting_info.ep); - } - } - } - xTaskCreate(esp_zb_task, "Zigbee_main", 4096, NULL, 24, NULL); + + uint8_t power_source = static_cast(this->is_battery_powered() ? EZB_AF_NODE_POWER_SOURCE_RECHARGEABLE_BATTERY + : EZB_AF_NODE_POWER_SOURCE_CONSTANT_POWER); + ezb_af_node_power_desc_t desc = { + .current_power_mode = EZB_AF_NODE_POWER_MODE_SYNC_ON_WHEN_IDLE, + .available_power_sources = power_source, + .current_power_source = power_source, + .current_power_source_level = EZB_AF_NODE_POWER_SOURCE_LEVEL_100_PERCENT, + }; + ezb_af_set_node_power_desc(&desc); + + // Start the Zigbee task with priority 1 to ensure main loop can still run even if Zigbee is busy + xTaskCreate(ezb_task, "Zigbee_main", 4096, NULL, 1, NULL); this->disable_loop(); // loop is only needed for processing events, so disable until we join a network } @@ -303,25 +311,28 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { - if (esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) { + if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n" " Device is joined to the network: %s\n" " Current channel: %d\n" " Short addr: 0x%04X\n" " Short pan id: 0x%04X", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER), - YESNO(esp_zb_bdb_dev_joined()), esp_zb_get_current_channel(), esp_zb_get_short_address(), - esp_zb_get_pan_id()); - esp_zb_lock_release(); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), + ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, "Zigbee\n" - " Model: %s\n" + " Model: %.*s\n" " Router: %s\n", - this->basic_cluster_data_.model, YESNO(this->device_role_ == ESP_ZB_DEVICE_TYPE_ROUTER)); + this->basic_cluster_data_.model[0], + reinterpret_cast(this->basic_cluster_data_.model + 1), + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); } } } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 34b2b827b6..f4bafac294 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -8,9 +8,8 @@ #include #include -#include "esp_zigbee_core.h" -#include "zboss_api.h" -#include "ha/esp_zigbee_ha_standard.h" +#include "esp_zigbee.h" +#include "ezbee/zha.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "zigbee_helpers_esp32.h" @@ -24,27 +23,26 @@ namespace esphome::zigbee { /* Zigbee configuration */ static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; +static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ -#define ESP_ZB_DEFAULT_RADIO_CONFIG() \ - { .radio_mode = ZB_RADIO_MODE_NATIVE, } - -#define ESP_ZB_DEFAULT_HOST_CONFIG() \ - { .host_connection_mode = ZB_HOST_CONNECTION_MODE_NONE, } +#define EZB_DEFAULT_RADIO_CONFIG() \ + { .radio_mode = ESP_ZIGBEE_RADIO_MODE_NATIVE, } uint8_t *get_zcl_string(const char *str, uint8_t max_size, bool use_max_size = false); class ZigbeeAttribute; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: + ZigbeeComponent(); void setup() override; void loop() override; void dump_config() override; - esp_err_t create_endpoint(uint8_t endpoint_id, zb_ha_standard_devs_e device_id, - esp_zb_cluster_list_t *esp_zb_cluster_list); + void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); - void create_default_cluster(uint8_t endpoint_id, zb_ha_standard_devs_e device_id); + void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); + bool register_device(); template void add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, @@ -53,15 +51,18 @@ class ZigbeeComponent : public Component { template void add_attr(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, uint8_t max_size, T value); + static bool app_signal_handler(const ezb_app_signal_t *app_signal); + static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); + void factory_reset() { - esp_zb_lock_acquire(portMAX_DELAY); - esp_zb_factory_reset(); // triggers a reboot - esp_zb_lock_release(); + esp_zigbee_lock_acquire(portMAX_DELAY); + esp_zigbee_factory_reset(); // triggers a reboot + esp_zigbee_lock_release(); } template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } - bool is_battery_powered() { return this->basic_cluster_data_.power_source == ESP_ZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } + bool is_battery_powered() { return this->basic_cluster_data_.power_source == EZB_ZCL_BASIC_POWER_SOURCE_BATTERY; } bool is_started() { return this->started; } bool is_connected() { return this->connected_; } std::atomic started = false; @@ -76,25 +77,20 @@ class ZigbeeComponent : public Component { uint8_t power_source; } basic_cluster_data_; bool connected_ = false; -#ifdef ZB_ED_ROLE - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ED; +#ifdef CONFIG_ZB_ZED + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; #else - esp_zb_nwk_device_type_t device_role_ = ESP_ZB_DEVICE_TYPE_ROUTER; + ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_ROUTER; #endif - esp_zb_attribute_list_t *create_basic_cluster_(); + void update_basic_cluster_(ezb_af_ep_desc_t ep_desc); template void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p); - // endpoint_list_ and attribute_list_ are only used during setup and are cleared afterwards - // value tuple could be replaced by struct - std::map> endpoint_list_; - // key tuple could be replaced by single 32 bit int with bit fields for endpoint, cluster and role - std::map, esp_zb_attribute_list_t *> attribute_list_; // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger // automations // key tuple could be replaced by single 64 (48) bit int with bit fields for endpoint, cluster, role and attr_id std::map, ZigbeeAttribute *> attributes_; - esp_zb_ep_list_t *esp_zb_ep_list_ = esp_zb_ep_list_create(); + ezb_af_device_desc_t dev_desc_; CallbackManager join_cb_{}; }; @@ -125,8 +121,15 @@ void ZigbeeComponent::add_attr(ZigbeeAttribute *attr, uint8_t endpoint_id, uint1 template void ZigbeeComponent::add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p) { - esp_zb_attribute_list_t *attr_list = this->attribute_list_[{endpoint_id, cluster_id, role}]; - esphome_zb_cluster_add_or_update_attr(cluster_id, attr_list, attr_id, value_p); + ezb_af_ep_desc_t ep_desc = ezb_af_device_get_endpoint_desc(this->dev_desc_, endpoint_id); + if (ep_desc == NULL) { + return; + } + ezb_zcl_cluster_desc_t cluster_desc = ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role); + if (cluster_desc == NULL) { + return; + } + esphome_zb_cluster_add_or_update_attr(cluster_id, cluster_desc, attr_id, value_p); if (attr != nullptr) { this->attributes_[{endpoint_id, cluster_id, role, attr_id}] = attr; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 086cdcc267..116dce8cc5 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -9,7 +9,6 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, add_partition, - require_libc_picolibc_newlib_compat, require_vfs_select, ) import esphome.config_validation as cv @@ -36,29 +35,29 @@ from .const import ( ANALOG_INPUT_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, - REPORT, ZigbeeAttribute, ) from .const_esp32 import ( ATTR_TYPE, CLUSTER_ID, + CLUSTER_ROLE, CONF_ATTRIBUTE_ID, CONF_ATTRIBUTES, CONF_CLUSTERS, - CONF_NUM, DEVICE_ID, DEVICE_TYPE, - KEY_BS_EP, - KEY_SENSOR_EP, + KEY_ZIGBEE_EP, ROLE, SCALE, ) -from .zigbee_ep_esp32 import create_ep, ep_configs +from .zigbee_ep_esp32 import add_ep, create_ep, ep_configs _LOGGER = logging.getLogger(__name__) @@ -76,7 +75,7 @@ def get_c_type(attr_type: str) -> Any | None: return cg.double if "STRING" in attr_type: return cg.std_string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return getattr(cg, "uint" + get_c_size(test.group(2), [8, 16, 32, 64])) return None @@ -89,14 +88,14 @@ def get_cv_by_type(attr_type: str) -> Any | None: return cv.float_ if "STRING" in attr_type: return cv.string - test = re.match(r"(^U?)(\d{1,2})(BITMAP$|BIT$|BIT_ENUM$|$)", attr_type) + test = re.match(r"^(DATA|UINT|MAP|ENUM)(\d{1,2})$", attr_type) if test and test.group(2): return cv.positive_int raise cv.Invalid(f"Zigbee: type {attr_type} not supported or implemented") def get_default_by_type(attr_type: str) -> str | bool | int | float: - if attr_type == "CHAR_STRING": + if attr_type == "STRING": return "" if attr_type == "BOOL": return False @@ -134,7 +133,6 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: ) as f: partitions_tab = f.read() for partition, types in [ - ("zb_storage", {"type": "data", "subtype": "fat", "size": 0x4000}), ("zb_fct", {"type": "data", "subtype": "fat", "size": 0x1000}), ]: if partition not in partitions_tab: @@ -149,6 +147,7 @@ def final_validate_esp32(config: ConfigType) -> ConfigType: raise cv.Invalid( f"Partition '{partition}' in your custom partition table has wrong format. It should be: '{partition}, {types['type']}, {types['subtype']}, , {types['size']},'" ) + create_ep(config.get(CONF_ROUTER)) return config @@ -191,29 +190,25 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: { CONF_ATTRIBUTE_ID: 0x100, CONF_VALUE: (apptype << 16) | 0xFFFF, - CONF_TYPE: "U32", + CONF_TYPE: "UINT32", }, ) ep[CONF_CLUSTERS][0][CONF_ATTRIBUTES].append( { CONF_ATTRIBUTE_ID: 0x75, CONF_VALUE: bacunit, - CONF_TYPE: "16BIT_ENUM", + CONF_TYPE: "ENUM16", }, ) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.setdefault(KEY_SENSOR_EP, []) - sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config def validate_binary_sensor_esp32(config: ConfigType) -> ConfigType: ep = copy.deepcopy(ep_configs["binary_input"]) setup_attributes(config, ep[CONF_CLUSTERS]) - zb_data = CORE.data.setdefault(KEY_ZIGBEE, {}) - binary_sensor_ep: list[dict] = zb_data.setdefault(KEY_BS_EP, []) - binary_sensor_ep.append(ep) + add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -233,15 +228,8 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ZB_ZCZR", True) else: add_idf_sdkconfig_option("CONFIG_ZB_ZED", True) - add_idf_sdkconfig_option("CONFIG_ZB_RADIO_NATIVE", True) if CONF_WIFI in CORE.config: add_idf_sdkconfig_option("CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE", 4096) - # The pre-built Zigbee library uses esp_log_default_level which requires - # dynamic log level control to be enabled - add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True) - # The pre-built Zigbee library is compiled against newlib which requires newlib - # reentrancy to be enabled with picolibc compatibility (IDF 6.0+ only). - require_libc_picolibc_newlib_compat() async def attributes_to_code( @@ -253,7 +241,7 @@ async def attributes_to_code( var.add_attr( ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], attr.get(CONF_MAX_LENGTH, 0), attr[CONF_VALUE], @@ -265,7 +253,7 @@ async def attributes_to_code( var, ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], attr[CONF_ATTRIBUTE_ID], ATTR_TYPE[attr[CONF_TYPE]], attr.get(SCALE, 1), @@ -274,11 +262,8 @@ async def attributes_to_code( await cg.register_component(attr_var, attr) cg.add(attr_var.add_attr(attr[CONF_VALUE])) - if CONF_REPORT in attr and attr[CONF_REPORT] in [ - REPORT["enable"], - REPORT["force"], - ]: - cg.add(attr_var.set_report(attr[CONF_REPORT] == REPORT["force"])) + if CONF_REPORT in attr: + cg.add(attr_var.set_report(attr[CONF_REPORT])) if CONF_DEVICE in attr: device = await cg.get_variable(attr[CONF_DEVICE]) @@ -287,27 +272,20 @@ async def attributes_to_code( async def esp32_to_code(config: ConfigType) -> "MockObj": - add_idf_component( - name="espressif/esp-zboss-lib", - ref="1.6.4", - ) add_idf_component( name="espressif/esp-zigbee-lib", - ref="1.6.8", + ref="2.0.3", ) # add sdkconfigs later so they can overwrite esp32 defaults CORE.add_job(_zigbee_add_sdkconfigs, config) # add partitions for zigbee - add_partition("zb_storage", "data", "fat", 0x4000) # 16KB add_partition("zb_fct", "data", "fat", 0x1000) # 4KB, minimum size # create endpoints zb_data = CORE.data.get(KEY_ZIGBEE, {}) - sensor_ep: list[dict] = zb_data.get(KEY_SENSOR_EP, []) - binary_sensor_ep: list[dict] = zb_data.get(KEY_BS_EP, []) - ep_list = create_ep(sensor_ep + binary_sensor_ep, config.get(CONF_ROUTER)) + ep_dict: dict[int, dict] = zb_data.get(KEY_ZIGBEE_EP, {}) # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) @@ -316,18 +294,18 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": var.set_basic_cluster( config[CONF_MODEL], "esphome", - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ) ) - for ep in ep_list: - cg.add(var.create_default_cluster(ep[CONF_NUM], DEVICE_ID[ep[DEVICE_TYPE]])) + for ep_num, ep in ep_dict.items(): + cg.add(var.create_default_cluster(ep_num, DEVICE_ID[ep[DEVICE_TYPE]])) for cl in ep.get(CONF_CLUSTERS, []): cg.add( var.add_cluster( - ep[CONF_NUM], + ep_num, CLUSTER_ID.get(cl[CONF_ID], cl[CONF_ID]), - cl[ROLE], + CLUSTER_ROLE[cl[ROLE]], ) ) - await attributes_to_code(var, ep[CONF_NUM], cl) + await attributes_to_code(var, ep_num, cl) return var diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 5254818df4..150be612f6 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -2,78 +2,59 @@ #ifdef USE_ESP32 #ifdef USE_ZIGBEE -#include "ha/esp_zigbee_ha_standard.h" #include "zigbee_helpers_esp32.h" +#include "ezbee/zha.h" -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { - esp_err_t ret; - ret = esp_zb_cluster_update_attr(attr_list, attr_id, value_p); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous attribute not found error"); - ret = esphome_zb_cluster_add_attr(cluster_id, attr_list, attr_id, value_p); + ezb_zcl_attr_desc_t attr_desc = ezb_zcl_cluster_get_attr_desc(cluster_desc, attr_id, EZB_ZCL_STD_MANUF_CODE); + if (attr_desc != NULL) { + return ezb_zcl_attr_desc_set_value(attr_desc, value_p); } - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Could not add attribute 0x%04X to cluster 0x%04X: %s", attr_id, cluster_id, - esp_err_to_name(ret)); - } - return ret; + return esphome_zb_cluster_add_attr(cluster_id, cluster_desc, attr_id, value_p); } -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask) { - esp_err_t ret; - ret = esp_zb_cluster_list_update_cluster(cluster_list, attr_list, cluster_id, role_mask); - if (ret != ESP_OK) { - ESP_LOGE("zigbee_helper", "Ignore previous cluster not found error"); - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - ret = esp_zb_cluster_list_add_basic_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - ret = esp_zb_cluster_list_add_identify_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - ret = esp_zb_cluster_list_add_analog_input_cluster(cluster_list, attr_list, role_mask); - break; - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - ret = esp_zb_cluster_list_add_binary_input_cluster(cluster_list, attr_list, role_mask); - break; - default: - ret = esp_zb_cluster_list_add_custom_cluster(cluster_list, attr_list, role_mask); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask) { + if (ezb_af_endpoint_get_cluster_desc(ep_desc, cluster_id, role_mask) != NULL) { + // Cluster already exists, nothing to do + return EZB_ERR_NONE; + } + ezb_zcl_cluster_desc_t cluster_desc; + cluster_desc = esphome_zb_default_cluster_dscr_create(cluster_id, role_mask); + return ezb_af_endpoint_add_cluster_desc(ep_desc, cluster_desc); +} + +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask) { + switch (cluster_id) { + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_create_cluster_desc(NULL, role_mask); + default: { + ezb_zcl_custom_cluster_config_t config = {0}; + config.cluster_id = cluster_id; + return ezb_zcl_custom_create_cluster_desc(&config, role_mask); } } - return ret; } -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id) { - switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_create(NULL); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_create(NULL); - default: - return esp_zb_zcl_attr_list_create(cluster_id); - } -} - -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p) { switch (cluster_id) { - case ESP_ZB_ZCL_CLUSTER_ID_BASIC: - return esp_zb_basic_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_IDENTIFY: - return esp_zb_identify_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_ANALOG_INPUT: - return esp_zb_analog_input_cluster_add_attr(attr_list, attr_id, value_p); - case ESP_ZB_ZCL_CLUSTER_ID_BINARY_INPUT: - return esp_zb_binary_input_cluster_add_attr(attr_list, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BASIC: + return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_IDENTIFY: + return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: + return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_BINARY_INPUT: + return ezb_zcl_binary_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); default: - return ESP_FAIL; + return EZB_ERR_NOT_FOUND; } } diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.h b/esphome/components/zigbee/zigbee_helpers_esp32.h index 0650c1689f..6898068b44 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -8,15 +8,14 @@ extern "C" { #endif -#include "esp_zigbee_core.h" +#include "esp_zigbee.h" -esp_err_t esphome_zb_cluster_list_add_or_update_cluster(uint16_t cluster_id, esp_zb_cluster_list_t *cluster_list, - esp_zb_attribute_list_t *attr_list, uint8_t role_mask); -esp_zb_attribute_list_t *esphome_zb_default_attr_list_create(uint16_t cluster_id); -esp_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, uint16_t attr_id, - void *value_p); -esp_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, esp_zb_attribute_list_t *attr_list, +ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); +ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); +ezb_zcl_cluster_desc_t esphome_zb_default_cluster_dscr_create(uint16_t cluster_id, uint8_t role_mask); +ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, + void *value_p); #ifdef __cplusplus } diff --git a/esphome/components/zigbee/zigbee_number_zephyr.h b/esphome/components/zigbee/zigbee_number_zephyr.h index aabb0392be..886e6c3223 100644 --- a/esphome/components/zigbee/zigbee_number_zephyr.h +++ b/esphome/components/zigbee/zigbee_number_zephyr.h @@ -98,7 +98,7 @@ void zb_zcl_analog_output_init_client(); namespace esphome::zigbee { -class ZigbeeNumber : public ZigbeeEntity, public Component { +class ZigbeeNumber final : public ZigbeeEntity, public Component { public: ZigbeeNumber(number::Number *n) : number_(n) {} void set_cluster_attributes(AnalogAttrsOutput &cluster_attributes) { diff --git a/esphome/components/zigbee/zigbee_sensor_zephyr.h b/esphome/components/zigbee/zigbee_sensor_zephyr.h index 37406f21d0..cd03cf8a2b 100644 --- a/esphome/components/zigbee/zigbee_sensor_zephyr.h +++ b/esphome/components/zigbee/zigbee_sensor_zephyr.h @@ -69,7 +69,7 @@ void zb_zcl_analog_input_init_client(); namespace esphome::zigbee { -class ZigbeeSensor : public ZigbeeEntity, public Component { +class ZigbeeSensor final : public ZigbeeEntity, public Component { public: explicit ZigbeeSensor(sensor::Sensor *sensor); void set_cluster_attributes(AnalogAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.h b/esphome/components/zigbee/zigbee_switch_zephyr.h index b774c23b3c..d2f71ce665 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.h +++ b/esphome/components/zigbee/zigbee_switch_zephyr.h @@ -63,7 +63,7 @@ void zb_zcl_binary_output_init_client(); namespace esphome::zigbee { -class ZigbeeSwitch : public ZigbeeEntity, public Component { +class ZigbeeSwitch final : public ZigbeeEntity, public Component { public: ZigbeeSwitch(switch_::Switch *s) : switch_(s) {} void set_cluster_attributes(BinaryAttrs &cluster_attributes) { this->cluster_attributes_ = &cluster_attributes; } diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index d462d2a403..3b4a465361 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -66,7 +66,7 @@ struct AnalogAttrsOutput : AnalogAttrs { float resolution; }; -class ZigbeeComponent : public Component { +class ZigbeeComponent final : public Component { public: void setup() override; void dump_config() override; diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 39ecadfddf..1647fb28ae 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -168,7 +168,7 @@ async def _attr_to_code(config: ConfigType) -> None: ), zigbee_assign( basic_attrs.power_source, - cg.RawExpression(POWER_SOURCE[config[CONF_POWER_SOURCE]]), + POWER_SOURCE[config[CONF_POWER_SOURCE]], ), zigbee_set_string(basic_attrs.location_id, ""), zigbee_assign( diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index d4d2ac974f..1dbce87307 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -8,7 +8,7 @@ static const char *const TAG = "Zio Ultrasonic"; namespace esphome::zio_ultrasonic { -class ZioUltrasonicComponent : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { +class ZioUltrasonicComponent final : public i2c::I2CDevice, public PollingComponent, public sensor::Sensor { public: void dump_config() override; diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 8a24bd57d6..5f56861e6d 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -18,13 +18,22 @@ static const char *const TAG = "zwave_proxy"; static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; -// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] +// GET_NETWORK_IDS response: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] +// We only read the home ID, so the node ID (1 byte in 8-bit mode, 2 bytes in 16-bit mode) and +// anything after it are not required to be present static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value -static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum +static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 7; // TYPE + CMD + HOME_ID(4) + checksum +static constexpr uint8_t ZWAVE_MIN_FRAME_LENGTH = 3; // TYPE + CMD + checksum (zero-payload frame) +static constexpr uint32_t ZWAVE_FRAME_TIMEOUT_MS = 1500; // Abandon a frame this long after its start (SOF) byte static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect +static constexpr bool is_bootloader_menu_byte(uint8_t byte) { + // Bootloader menu output is printable ASCII plus CR/LF, ending with a NUL terminator + return byte == 0 || byte == '\r' || byte == '\n' || (byte >= 0x20 && byte <= 0x7E); +} + static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum // XOR all bytes between SOF and checksum position (exclusive) @@ -74,6 +83,11 @@ bool ZWaveProxy::can_proceed() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->setup_time_ > HOME_ID_TIMEOUT_MS) { ESP_LOGW(TAG, "Timeout reading Home ID during setup"); + // The modem may simply still be booting; keep querying from loop() using the same retry + // machinery as a reconnect. This adds no setup delay — clients are notified of the home ID + // via the HOME_ID_CHANGE message whenever it finally arrives. + this->reconnect_time_ = now; + this->query_retries_ = 0; return true; // Proceed anyway after timeout } @@ -98,7 +112,18 @@ void ZWaveProxy::loop() { } this->process_uart_(); - this->status_clear_warning(); + + // Abandon a stalled frame reception. The Z-Wave API specification requires a receiver to abort + // a data frame reception lasting more than 1500 ms after the SOF byte, without sending a NAK. + // Without this, the stale bytes would silently corrupt the next frame. Any SEND_* state was + // already resolved by response_handler_() above, so a state other than WAIT_START here always + // means we are mid-frame. + if (this->parsing_state_ != ZWAVE_PARSING_STATE_WAIT_START && + App.get_loop_component_start_time() - this->frame_start_time_ > ZWAVE_FRAME_TIMEOUT_MS) { + ESP_LOGW(TAG, "Timeout waiting for frame data; resetting parser"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + } } void ZWaveProxy::process_uart_slow_() { @@ -112,19 +137,24 @@ void ZWaveProxy::process_uart_slow_() { } if (this->parse_byte_(byte)) { // Check if this is a GET_NETWORK_IDS response frame - // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID][...] + // Frame format: [SOF][LENGTH][TYPE][CMD][HOME_ID(4)][NODE_ID(1 or 2)][...] + // Bootloader output is excluded up front: a completed bootloader "frame" is menu text, so + // buffer_[1..3] would be meaningless (and possibly never written). Outside bootloader mode, + // the parser guarantees a completed frame starts with SOF, so buffer_[0] needs no check. // We verify: - // - buffer_[0]: Start of frame marker (0x01) - // - buffer_[1]: Length field must be >= 9 to contain all required data + // - buffer_[1]: Length field must be >= 7 so the frame contains the full home ID // - buffer_[2]: Command type (0x01 for response) // - buffer_[3]: Command ID (0x20 for GET_NETWORK_IDS) - if (this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS && this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && - this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && this->buffer_[0] == ZWAVE_FRAME_TYPE_START) { + if (!this->in_bootloader_ && this->buffer_[1] >= ZWAVE_MIN_GET_NETWORK_IDS_LENGTH && + this->buffer_[2] == ZWAVE_COMMAND_TYPE_RESPONSE && this->buffer_[3] == ZWAVE_COMMAND_GET_NETWORK_IDS) { // Store the 4-byte Home ID, which starts at offset 4, and notify connected clients if it changed // The frame parser has already validated the checksum and ensured all bytes are present if (this->set_home_id_(&this->buffer_[4])) { + char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; + ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); this->send_homeid_changed_msg_(); } + this->home_id_ready_ = true; } ESP_LOGV(TAG, "Sending to client: %s", YESNO(this->api_connection_ != nullptr)); if (this->api_connection_ != nullptr) { @@ -140,14 +170,19 @@ void ZWaveProxy::process_uart_slow_() { } } } while (this->available()); + // Reaching here means every read succeeded, so clear any earlier read-failure warning. + // (An early return on read failure skips this, leaving the warning visible until the + // next successful drain.) + this->status_clear_warning(); } void ZWaveProxy::dump_config() { char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGCONFIG(TAG, - "Z-Wave Proxy:\n" - " Home ID: %s", - format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); + ESP_LOGCONFIG( + TAG, + "Z-Wave Proxy:\n" + " Home ID: %s", + this->home_id_ready_ ? format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size()) : "unknown"); } void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { @@ -160,10 +195,20 @@ void ZWaveProxy::api_connection_authenticated(api::APIConnection *conn) { void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::enums::ZWaveProxyRequestType type) { switch (type) { case api::enums::ZWAVE_PROXY_REQUEST_TYPE_SUBSCRIBE: - if (this->api_connection_ != nullptr) { - ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + if (this->api_connection_ == api_connection) { + ESP_LOGV(TAG, "API connection is already subscribed"); return; } + if (this->api_connection_ != nullptr) { + // A living subscriber keeps exclusive access. Its connection may be dead without + // loop() having noticed yet (e.g. the client crashed and reconnected quickly); + // in that case let the new client take over instead of locking it out. + if (this->api_connection_->is_connection_setup()) { + ESP_LOGE(TAG, "Only one API subscription is allowed at a time"); + return; + } + ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + } this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; @@ -222,6 +267,7 @@ void ZWaveProxy::retry_home_id_query_() { void ZWaveProxy::clear_home_id_() { static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; if (this->set_home_id_(ZERO_HOME_ID)) { + ESP_LOGV(TAG, "Home ID cleared"); this->send_homeid_changed_msg_(); } this->home_id_ready_ = false; @@ -237,13 +283,20 @@ bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { return false; // No change } std::memcpy(this->home_id_.data(), new_home_id, this->home_id_.size()); - char hex_buf[format_hex_pretty_size(ZWAVE_HOME_ID_SIZE)]; - ESP_LOGI(TAG, "Home ID: %s", format_hex_pretty_to(hex_buf, this->home_id_.data(), this->home_id_.size())); - this->home_id_ready_ = true; return true; // Home ID was changed } -void ZWaveProxy::send_frame(const uint8_t *data, size_t length) { +void ZWaveProxy::send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) { + // Only the subscribed client may talk to the Z-Wave module; a frame from any other + // (authenticated but unsubscribed) client would interleave with the subscriber's traffic + if (api_connection != this->api_connection_) { + ESP_LOGW(TAG, "Ignoring frame from unsubscribed client"); + return; + } + this->send_frame_(data, length); +} + +void ZWaveProxy::send_frame_(const uint8_t *data, size_t length) { // Safety: validate pointer before any access if (data == nullptr) { ESP_LOGE(TAG, "Null data pointer"); @@ -289,7 +342,7 @@ void ZWaveProxy::send_simple_command_(const uint8_t command_id) { // Where LENGTH=0x03 (3 bytes: TYPE + CMD + CHECKSUM) uint8_t cmd[] = {0x01, 0x03, 0x00, command_id, 0x00}; cmd[4] = calculate_frame_checksum(cmd, sizeof(cmd)); - this->send_frame(cmd, sizeof(cmd)); + this->send_frame_(cmd, sizeof(cmd)); } bool ZWaveProxy::parse_byte_(uint8_t byte) { @@ -300,9 +353,12 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { this->parse_start_(byte); break; case ZWAVE_PARSING_STATE_WAIT_LENGTH: - if (!byte) { + if (byte < ZWAVE_MIN_FRAME_LENGTH) { ESP_LOGW(TAG, "Invalid LENGTH: %u", byte); this->parsing_state_ = ZWAVE_PARSING_STATE_SEND_NAK; + // Send the NAK now; otherwise any bytes already buffered behind this one would be + // silently discarded by the SEND_NAK case below until the next loop() iteration + this->response_handler_(); return false; } ESP_LOGVV(TAG, "Received LENGTH: %u", byte); @@ -319,7 +375,9 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { case ZWAVE_PARSING_STATE_WAIT_COMMAND_ID: this->buffer_[this->buffer_index_++] = byte; ESP_LOGVV(TAG, "Received COMMAND ID: 0x%02X", byte); - this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_PAYLOAD; + // A zero-payload frame (LENGTH == 3) has its checksum immediately after the command ID + this->parsing_state_ = this->buffer_index_ >= this->end_frame_after_ ? ZWAVE_PARSING_STATE_WAIT_CHECKSUM + : ZWAVE_PARSING_STATE_WAIT_PAYLOAD; break; case ZWAVE_PARSING_STATE_WAIT_PAYLOAD: this->buffer_[this->buffer_index_++] = byte; @@ -347,12 +405,24 @@ bool ZWaveProxy::parse_byte_(uint8_t byte) { break; } case ZWAVE_PARSING_STATE_READ_BL_MENU: - if (this->buffer_index_ >= this->buffer_.size()) { + // This state is tentative (see parse_start_): bootloader mode is committed only when a + // plausible menu — printable text ending in a NUL terminator — completes. A byte that + // cannot be menu text means the 0x0D that started this state was not a menu after all, + // so re-parse that byte as a frame start; it may be the SOF/ACK/NAK of real traffic. + if (this->buffer_index_ >= this->buffer_.size() || !is_bootloader_menu_byte(byte)) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->parse_start_(byte); break; } this->buffer_[this->buffer_index_++] = byte; if (!byte) { + if (!this->in_bootloader_) { + ESP_LOGD(TAG, "Entered bootloader mode"); + this->in_bootloader_ = true; + // Reset response deduplication: in bootloader mode, single-byte client writes (XMODEM + // ACK/NAK/CAN) are raw data and must never be suppressed as duplicate responses + this->last_response_ = 0; + } this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; frame_completed = true; } @@ -378,15 +448,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; } + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: ESP_LOGV(TAG, "Received BL_MENU"); - if (!this->in_bootloader_) { - ESP_LOGD(TAG, "Entered bootloader mode"); - this->in_bootloader_ = true; - } + // Read the menu tentatively: a stray 0x0D can equally appear in garbled data after the + // parser loses frame alignment, so bootloader mode is only committed once a plausible + // menu completes (see READ_BL_MENU handling in parse_byte_) + this->frame_start_time_ = App.get_loop_component_start_time(); this->buffer_[this->buffer_index_++] = byte; this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; @@ -403,7 +474,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { ESP_LOGV(TAG, "Received CAN"); break; default: - ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); + ESP_LOGV(TAG, "Unrecognized START: 0x%02X", byte); return; } // Forward response (ACK/NAK/CAN) back to client for processing diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index dc5dc46abc..cb60139ef8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -49,7 +49,7 @@ enum ZWaveProxyFeature : uint32_t { FEATURE_ZWAVE_PROXY_ENABLED = 1 << 0, }; -class ZWaveProxy : public uart::UARTDevice, public Component { +class ZWaveProxy final : public uart::UARTDevice, public Component { public: ZWaveProxy(); @@ -68,13 +68,16 @@ class ZWaveProxy : public uart::UARTDevice, public Component { return encode_uint32(this->home_id_[0], this->home_id_[1], this->home_id_[2], this->home_id_[3]); } - void send_frame(const uint8_t *data, size_t length); + // Send a frame from an API client to the Z-Wave module. Frames from any connection other + // than the currently subscribed one are ignored. + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length); protected: - bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. - void clear_home_id_(); // Clear home ID and notify API clients - void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions - void retry_home_id_query_(); // Retry home ID query after reconnect + void send_frame_(const uint8_t *data, size_t length); // Write a frame to the Z-Wave module + bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -114,6 +117,7 @@ class ZWaveProxy : public uart::UARTDevice, public Component { api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) + uint32_t frame_start_time_{0}; // Timestamp of the current frame's start byte (reception timeout) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer diff --git a/esphome/components/zyaura/zyaura.h b/esphome/components/zyaura/zyaura.h index 7c7954dec2..5a451ea463 100644 --- a/esphome/components/zyaura/zyaura.h +++ b/esphome/components/zyaura/zyaura.h @@ -57,7 +57,7 @@ class ZaSensorStore { }; /// Component for reading temperature/co2/humidity measurements from ZyAura sensors. -class ZyAuraSensor : public PollingComponent { +class ZyAuraSensor final : public PollingComponent { public: void set_pin_clock(InternalGPIOPin *pin) { pin_clock_ = pin; } void set_pin_data(InternalGPIOPin *pin) { pin_data_ = pin; } diff --git a/esphome/config.py b/esphome/config.py index 91e6df8bad..976faed447 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -20,6 +20,7 @@ from esphome.const import ( CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_ID, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_PACKAGES, CONF_PLATFORM, @@ -137,6 +138,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): @@ -508,6 +599,18 @@ class LoadValidationStep(ConfigValidationStep): CORE.loaded_integrations.add(self.domain) # For platform components, normalize conf before creating MetadataValidationStep if component.is_platform_component: + # Legacy config migration: allow a platform component to rewrite a + # pre-platform-format top-level config (e.g. a bare list or legacy + # dict form) into the normalized list of `platform:` tagged entries. + # Removable deprecation shim hook; no-op for components that do not + # define LEGACY_CONFIG_MIGRATE. + if ( + (migrate := component.legacy_config_migrate) is not None + and self.conf + and not isinstance(self.conf, core.AutoLoad) + and (migrated := migrate(self.conf)) is not None + ): + result[self.domain] = self.conf = migrated if not self.conf: result[self.domain] = self.conf = [] elif not isinstance(self.conf, list): @@ -1048,6 +1151,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: @@ -1082,6 +1197,24 @@ def validate_config( ) return result + # Warn about any keys silently dropped by `<<` merge includes (shallow, + # first-wins). The esphome: section is now known, so we can honor its + # `merge_warnings:` opt-out. Always drain the queue to keep it from leaking + # into a later run. + if (dropped := yaml_util.take_dropped_merge_keys()) and ( + not isinstance(esphome_conf := config[CONF_ESPHOME], dict) + or esphome_conf.get(CONF_MERGE_WARNINGS, True) + ): + for key, location in dict.fromkeys(dropped): + _LOGGER.warning( + "Key '%s' (%s) was dropped while processing a '<<' merge because it " + "is already defined. Merge keys don't combine sections - the first " + "definition wins. Use 'packages:' to merge sections, or set " + "'esphome: { merge_warnings: false }' to silence this.", + key, + location, + ) + # Snapshot the user's config before any schema validation defaults are # applied. preload_core_config and later validation steps rewrite entries # in-place with defaulted values; deep-copying here preserves the diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 3ff2c975a1..16f0a63aa0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,7 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, @@ -292,10 +292,14 @@ class Visibility(StrEnum): the same way. ESPHome itself ignores the value at runtime; consumers downstream of the schema dump act on it. - A field with no ``visibility`` set (the default) renders on the - editor's main form. The two values below are points along a - single axis of "how prominently to surface this": + Three points along a single axis of "how prominently to surface + this", from least to most hidden: + - ``UI`` — always render on the editor's main form. Use to + promote an ``Optional`` that would otherwise fall through to + the advanced disclosure (see the default rule below): the + "headline" config a user reaches for first (e.g. a sensor's + ``name`` or its primary pin/address). - ``ADVANCED`` — render under the editor's "advanced settings" disclosure. Use for fields whose default is right for ~all users (e.g. ``update_interval`` on time platforms — 15 min is @@ -307,25 +311,35 @@ class Visibility(StrEnum): tweaks can break boot). The YAML escape hatch stays available for the rare power-user override. - The single-axis shape encodes "yaml-only is strictly stronger - than advanced" at the type level — there's no way to ask for - both at once, and no way to set a contradictory state like - "advanced=False, yaml_only=True". + Default when unset (``visibility=None``): resolved by the + consumer, not encoded on the marker. A schema-aware editor + treats an ``Optional`` with no setting as ``ADVANCED`` (most + optional knobs have sensible defaults and would clutter the + form), and a ``Required`` with no setting as ``UI`` (a required + field needs the user's attention). Pass an explicit value to + override either default — most commonly ``UI`` to keep a + high-value ``Optional`` on the main form. + + The single-axis shape encodes the strictness ladder + (``UI`` < ``ADVANCED`` < ``YAML_ONLY``) at the type level — + there's no way to set a contradictory state. Per-field; the dumper walks recursively into nested schemas - and emits each field's setting independently. Cascading - semantics — "a stricter parent makes its descendants at-least - as strict" — belong on the consumer side: the schema marker - is faithfully what the field author wrote, and a consumer that - cares about effective visibility walks the parent chain and - takes the strictest setting. ``YAML_ONLY`` is strictly stronger - than ``ADVANCED``, which is strictly stronger than no setting. - Inner fields can declare their own visibility; an inner + and emits each field's setting independently, omitting the key + when unset so the dump stays compact and the per-field default + is the consumer's to apply. Cascading semantics — "a stricter + parent makes its descendants at-least as strict" — belong on the + consumer side: the schema marker is faithfully what the field + author wrote, and a consumer that cares about effective + visibility walks the parent chain and takes the strictest + setting. Inner fields can declare their own visibility; an inner ``YAML_ONLY`` under an ``ADVANCED`` parent stays ``YAML_ONLY``, - and the consumer's cascade keeps siblings under the parent at - ``ADVANCED`` regardless of their own (less-strict) setting. + and the consumer's cascade keeps a ``UI`` sibling under an + ``ADVANCED`` parent at ``ADVANCED`` regardless of its own + (less-strict) setting. """ + UI = "ui" ADVANCED = "advanced" YAML_ONLY = "yaml_only" @@ -347,6 +361,9 @@ class Optional(vol.Optional): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. + Left unset, an ``Optional`` is treated as ``Visibility.ADVANCED`` + by schema-aware editors; pass ``Visibility.UI`` to keep it on the + main form. """ def __init__( @@ -369,9 +386,11 @@ class Required(vol.Required): See :class:`Visibility` for the ``visibility`` kwarg — a UI hint for schema-driven editors that doesn't affect validation. - Required fields rarely need it (a required field by definition - needs the user's attention) but the kwarg is exposed for - symmetry so consumers can apply uniform logic across key markers. + Required fields rarely need it: left unset, a ``Required`` is + treated as on the main form (``Visibility.UI``) by schema-aware + editors, since a required field needs the user's attention. The + kwarg is exposed for symmetry so consumers can apply uniform + logic across key markers. """ def __init__( @@ -859,7 +878,38 @@ def only_with_framework( only_on_esp32 = only_on(PLATFORM_ESP32) only_on_esp8266 = only_on(PLATFORM_ESP8266) only_on_nrf52 = only_on(PLATFORM_NRF52) -only_on_rp2040 = only_on(PLATFORM_RP2040) +only_on_rp2 = only_on(PLATFORM_RP2) + +# CORE.data key for the "deprecation warning already fired this run" flag. +# Deduped via CORE.data (cleared between runs) to match the framework-alias +# pattern; one warning per `esphome config|compile|run` invocation is enough. +_ONLY_ON_RP2040_DEPRECATED_KEY = "_cv_only_on_rp2040_deprecated_warned" + + +def only_on_rp2040(obj): + """Deprecated — kept as a back-compat shim for external custom components. + + Pre-RP2350, this was the family check for the RP2 platform; with RP2350 + landing under the same target platform, the variant axis is now exposed + by the rp2 component itself. New code should use one of: + + * :func:`only_on_rp2` — family-level gate (matches the esp32 pattern; + same semantics as the pre-RP2350 ``only_on_rp2040``). + * ``rp2.only_on_variant(supported=[VARIANT_RP2040])`` — variant-level + gate, rejects RP2350 boards on the rp2 platform. + + Scheduled for removal in 2027.7.0. + """ + if not CORE.data.get(_ONLY_ON_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "cv.only_on_rp2040 is deprecated; use cv.only_on_rp2 for the " + "family gate, or rp2.only_on_variant(supported=[VARIANT_RP2040]) " + "for the variant gate. Removed in 2027.7.0." + ) + CORE.data[_ONLY_ON_RP2040_DEPRECATED_KEY] = True + return only_on_rp2(obj) + + only_with_arduino = only_with_framework(Framework.ARDUINO) @@ -1220,21 +1270,60 @@ def mac_address(value): return core.MACAddress(*parts_int) -def bind_key(value, *, name="Bind key"): - value = string_strict(value) - parts = [value[i : i + 2] for i in range(0, len(value), 2)] - if len(parts) != 16: - raise Invalid(f"{name} must consist of 16 hexadecimal numbers") - parts_int = [] - if any(len(part) != 2 for part in parts): - raise Invalid(f"{name} must be format XX") - for part in parts: - try: - parts_int.append(int(part, 16)) - except ValueError: - raise Invalid(f"{name} must be hex values from 00 to FF") from None +_BIND_KEY_MISSING = object() - return "".join(f"{part:02X}" for part in parts_int) + +class BindKeyValidator(SensitiveValidator): + """Sensitive validator for a 16-byte hex bind/encryption key. + + Use bare as a validator (``cv.bind_key``) for the default error wording, or + call it with a custom ``name`` (``cv.bind_key(name="Decryption key")``) to + get a validator with tailored error messages. Either way the value is marked + sensitive so frontends mask it and dump tooling redacts it. + """ + + def __init__(self, name: str = "Bind key") -> None: + self._name = name + super().__init__(self._validate) + + def _validate(self, value: typing.Any) -> str: + value = string_strict(value) + parts = [value[i : i + 2] for i in range(0, len(value), 2)] + if len(parts) != 16: + raise Invalid(f"{self._name} must consist of 16 hexadecimal numbers") + parts_int = [] + if any(len(part) != 2 for part in parts): + raise Invalid(f"{self._name} must be format XX") + for part in parts: + try: + parts_int.append(int(part, 16)) + except ValueError: + raise Invalid( + f"{self._name} must be hex values from 00 to FF" + ) from None + + return "".join(f"{part:02X}" for part in parts_int) + + def __call__( + self, value: typing.Any = _BIND_KEY_MISSING, *, name: str | None = None + ) -> typing.Any: + if value is _BIND_KEY_MISSING: + # Factory usage: return a validator with customized error wording. + return BindKeyValidator(name if name is not None else self._name) + if name is not None and name != self._name: + # Direct validation with a one-off custom name. + return BindKeyValidator(name)(value) + return super().__call__(value) + + def __repr__(self) -> str: + # ``self.inner`` is a bound method of this instance, so the inherited + # ``SensitiveValidator.__repr__`` (which returns ``repr(self.inner)``) + # would recurse infinitely. Provide a stable, name-keyed repr instead so + # ``build_language_schema`` dedup and voluptuous errors stay sane. + return f"bind_key({self._name!r})" + + +bind_key = BindKeyValidator() def uuid(value): @@ -1951,7 +2040,24 @@ def _get_default_key(*args): class SplitDefault(Optional): - """Mark this key to have a split default for ESP8266/ESP32.""" + """Mark this key to have a split default per target platform / variant / framework. + + Defaults are passed as kwargs keyed on the platform identifier; the most + specific match wins. Lookup order (first hit wins): + + 1. ``__`` — e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino`` + 2. ``_`` — e.g. ``esp32_c3``, ``rp2_2040`` + 3. ``_`` — e.g. ``esp32_arduino``, + ``rp2_arduino`` + 4. ```` — e.g. ``esp32``, ``rp2`` + + For ESP32 the variant strips the ``ESP32`` prefix from + :data:`esp32.VARIANT_*` constants (``ESP32C3`` → ``c3``). For RP2 the + variant strips just ``RP`` (``RP2040`` → ``2040``, ``RP2350`` → ``2350``) + so kwargs read naturally — `rp2_2040=...` is the override for the + Pico / Pico W and `rp2_2350=...` is the override for the Pico 2. + """ def __init__(self, key, **kwargs): super().__init__(key) @@ -1973,6 +2079,22 @@ class SplitDefault(Optional): keys += _get_default_key(variant, framework) keys += _get_default_key(variant) keys += _get_default_key(framework) + elif CORE.is_rp2: + # Strip the "RP" prefix to leave the chip number, mirroring + # the ESP32 "platform stripped from variant" convention so + # kwargs stay short (``rp2_2040`` rather than ``rp2_rp2040``). + # Variant lookup is defensive: validators may run before the + # rp2 component's ``set_core_data`` (or in tests that wire a + # partial ``CORE.data``); in that case we just skip the + # variant-specific keys and fall through to the base + # platform/framework defaults. + raw_variant = CORE.data.get("rp2", {}).get("variant") + framework = CORE.target_framework + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys += _get_default_key(variant, framework) + keys += _get_default_key(variant) + keys += _get_default_key(framework) keys += _get_default_key() for key in keys: if self._defaults.get(key) is not None: @@ -2171,16 +2293,25 @@ MQTT_COMPONENT_AVAILABILITY_SCHEMA = Schema( } ) +# Per-entity MQTT plumbing — integration metadata, never a primary UI field. MQTT_COMPONENT_SCHEMA = Schema( { - Optional(CONF_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_RETAIN): All(requires_component("mqtt"), boolean), - Optional(CONF_DISCOVERY): All(requires_component("mqtt"), boolean), - Optional(CONF_SUBSCRIBE_QOS): All(requires_component("mqtt"), mqtt_qos), - Optional(CONF_STATE_TOPIC): All( + Optional(CONF_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_DISCOVERY, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), + Optional(CONF_SUBSCRIBE_QOS, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), mqtt_qos + ), + Optional(CONF_STATE_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(publish_topic) ), - Optional(CONF_AVAILABILITY): All( + Optional(CONF_AVAILABILITY, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), Any(None, MQTT_COMPONENT_AVAILABILITY_SCHEMA) ), } @@ -2188,10 +2319,12 @@ MQTT_COMPONENT_SCHEMA = Schema( MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend( { - Optional(CONF_COMMAND_TOPIC): All( + Optional(CONF_COMMAND_TOPIC, visibility=Visibility.ADVANCED): All( requires_component("mqtt"), templatable(subscribe_topic) ), - Optional(CONF_COMMAND_RETAIN): All(requires_component("mqtt"), boolean), + Optional(CONF_COMMAND_RETAIN, visibility=Visibility.ADVANCED): All( + requires_component("mqtt"), boolean + ), } ) @@ -2266,12 +2399,16 @@ def string_no_slash(value): ENTITY_BASE_SCHEMA = Schema( { - Optional(CONF_NAME): _validate_entity_name, - Optional(CONF_INTERNAL): boolean, - Optional(CONF_DISABLED_BY_DEFAULT, default=False): boolean, - Optional(CONF_ICON): icon, - Optional(CONF_ENTITY_CATEGORY): entity_category, - Optional(CONF_DEVICE_ID): sub_device_id, + # The name is every entity's headline field — keep it on the + # main form rather than letting it fall through to advanced. + Optional(CONF_NAME, visibility=Visibility.UI): _validate_entity_name, + Optional(CONF_INTERNAL, visibility=Visibility.ADVANCED): boolean, + Optional( + CONF_DISABLED_BY_DEFAULT, default=False, visibility=Visibility.ADVANCED + ): boolean, + Optional(CONF_ICON, visibility=Visibility.ADVANCED): icon, + Optional(CONF_ENTITY_CATEGORY, visibility=Visibility.ADVANCED): entity_category, + Optional(CONF_DEVICE_ID, visibility=Visibility.ADVANCED): sub_device_id, } ) @@ -2404,18 +2541,58 @@ def require_framework_version( extra_message=None, **kwargs, ): + """Constrain the configured framework version per target platform / variant. + + Kwargs are keyed by ``_`` (e.g. ``esp32_arduino``, + ``rp2_arduino``) with optional variant-specific overrides keyed by + ``__`` (e.g. ``esp32_c3_arduino``, + ``rp2_2040_arduino``, ``rp2_2350_arduino``). Variant overrides win when + the configured variant matches; otherwise the base platform key is used. + + Special cases: ``host`` (with host framework) and ``esp_idf`` (any ESP32 + on ESP-IDF) bypass variant lookup. + """ + def validator(value): core_data = CORE.data[KEY_CORE] framework = core_data[KEY_TARGET_FRAMEWORK] + keys_to_try: list[str] = [] if CORE.is_host and framework == "host": - key = "host" + keys_to_try.append("host") elif framework == "esp-idf": - key = "esp_idf" + keys_to_try.append("esp_idf") else: - key = CORE.target_platform + "_" + framework + # Try variant-specific key first (mirrors the SplitDefault + # precedence). ESP32 strips its platform prefix from variant + # constants; RP2 strips just ``RP`` to keep chip-number kwargs + # (``rp2_2040``, ``rp2_2350``). + if CORE.is_esp32: + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant - if key not in kwargs: + # Guard against tests that wire CORE.data without an + # esp32 variant block; same defensive intent as the rp2 + # branch below. + try: + variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() + except (KeyError, AttributeError): + variant = "" + if variant: + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + elif CORE.is_rp2: + # Defensive lookup — see the matching block in + # ``SplitDefault.default``: the rp2 component's + # ``set_core_data`` may not have populated + # ``CORE.data["rp2"]["variant"]`` yet (validators run + # during schema validation, before code-gen). + raw_variant = CORE.data.get("rp2", {}).get("variant") + if raw_variant: + variant = raw_variant.removeprefix("RP").lower() + keys_to_try.append(f"{CORE.target_platform}_{variant}_{framework}") + keys_to_try.append(f"{CORE.target_platform}_{framework}") + + key = next((k for k in keys_to_try if k in kwargs), None) + if key is None: msg = f"This feature is incompatible with {CORE.target_platform.upper()} using {framework} framework" if extra_message: msg += f". {extra_message}" diff --git a/esphome/const.py b/esphome/const.py index 7c7f0d0d5f..0b0d3c2e4a 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.5" +__version__ = "2026.7.0" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -33,7 +33,12 @@ class Platform(StrEnum): LIBRETINY_OLDSTYLE = "libretiny" LN882X = "ln882x" NRF52 = "nrf52" - RP2040 = "rp2040" + RP2 = "rp2" # canonical name for the RP2 family (RP2040, RP2350, …) + # Deprecated: use Platform.RP2 instead. Python enum aliasing makes this + # the same member as RP2 (same string value), so ``Platform.RP2040`` and + # ``Platform.RP2`` remain interchangeable for external custom components. + # Scheduled for removal in 2027.7.0. + RP2040 = "rp2" RTL87XX = "rtl87xx" @@ -86,6 +91,9 @@ class PlatformFramework(Enum): # Arduino framework platforms ESP8266_ARDUINO = (Platform.ESP8266, Framework.ARDUINO) + RP2_ARDUINO = (Platform.RP2, Framework.ARDUINO) + # Deprecated: use PlatformFramework.RP2_ARDUINO instead. Kept as an + # alias for backwards compatibility; scheduled for removal in 2027.7.0. RP2040_ARDUINO = (Platform.RP2040, Framework.ARDUINO) BK72XX_ARDUINO = (Platform.BK72XX, Framework.ARDUINO) RTL87XX_ARDUINO = (Platform.RTL87XX, Framework.ARDUINO) @@ -106,6 +114,9 @@ PLATFORM_HOST = Platform.HOST PLATFORM_LIBRETINY_OLDSTYLE = Platform.LIBRETINY_OLDSTYLE PLATFORM_LN882X = Platform.LN882X PLATFORM_NRF52 = Platform.NRF52 +PLATFORM_RP2 = Platform.RP2 +# Deprecated: use PLATFORM_RP2 instead. Kept as a back-compat alias; +# scheduled for removal in 2027.7.0. PLATFORM_RP2040 = Platform.RP2040 PLATFORM_RTL87XX = Platform.RTL87XX @@ -613,6 +624,7 @@ CONF_MEASUREMENT_SEQUENCE_NUMBER = "measurement_sequence_number" CONF_MEDIA_PLAYER = "media_player" CONF_MEDIUM = "medium" CONF_MEMORY_BLOCKS = "memory_blocks" +CONF_MERGE_WARNINGS = "merge_warnings" CONF_MESSAGE = "message" CONF_METHANE = "methane" CONF_METHOD = "method" @@ -975,6 +987,7 @@ CONF_STEP_PIN = "step_pin" CONF_STILL_THRESHOLD = "still_threshold" CONF_STOP = "stop" CONF_STOP_ACTION = "stop_action" +CONF_STORAGE = "storage" CONF_STORE_BASELINE = "store_baseline" CONF_SUBNET = "subnet" CONF_SUBSCRIBE_QOS = "subscribe_qos" @@ -1242,6 +1255,7 @@ UNIT_KILOVOLT_AMPS_REACTIVE_HOURS = "kvarh" UNIT_KILOWATT = "kW" UNIT_KILOWATT_HOURS = "kWh" UNIT_LITRE = "L" +UNIT_LITRE_PER_HOUR = "L/h" UNIT_LITRE_PER_SECOND = "L/s" UNIT_LUX = "lx" UNIT_MEGAJOULE = "MJ" @@ -1350,6 +1364,7 @@ DEVICE_CLASS_PRECIPITATION_INTENSITY = "precipitation_intensity" DEVICE_CLASS_PRESENCE = "presence" DEVICE_CLASS_PRESSURE = "pressure" DEVICE_CLASS_PROBLEM = "problem" +DEVICE_CLASS_RADON = "radon" DEVICE_CLASS_REACTIVE_ENERGY = "reactive_energy" DEVICE_CLASS_REACTIVE_POWER = "reactive_power" DEVICE_CLASS_RESTART = "restart" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 21ff7ef07c..bf637d4c1f 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -8,6 +8,7 @@ import re from typing import TYPE_CHECKING, Any from esphome.const import ( + CONF_BUILD_PATH, CONF_COMMENT, CONF_ESPHOME, CONF_ETHERNET, @@ -25,7 +26,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, Toolchain, ) @@ -52,6 +53,11 @@ _LOGGER = logging.getLogger(__name__) # Key for tracking controller count in CORE.data for ControllerRegistry StaticVector sizing KEY_CONTROLLER_REGISTRY_COUNT = "controller_registry_count" +# CORE.data key for the "is_rp2040 deprecation warning already fired this +# run" flag. Mirrors the ``cv.only_on_rp2040`` dedupe pattern; cleared +# between runs so each fresh invocation warns once. +_IS_RP2040_DEPRECATED_KEY = "_core_is_rp2040_deprecated_warned" + class EsphomeError(Exception): """General ESPHome exception occurred.""" @@ -569,6 +575,9 @@ class EsphomeCore: self.build_path: Path | None = None # The validated configuration, this is None until the config has been validated self.config: ConfigType | None = None + # The raw configuration as read from YAML (after packages/substitutions), + # available during validation before the config is fully validated + self.raw_config: ConfigType | None = None # YAML frontmatter loaded from user YAML files. Frontmatter is a leading # YAML document separated by `---` from the actual configuration. It is # ignored by config validation and code generation, but kept here so it @@ -591,6 +600,9 @@ class EsphomeCore: self.platformio_libraries: dict[str, Library] = {} # A set of build flags to set in the platformio project self.build_flags: set[str] = set() + # A set of build flags that apply to C++ compiles only (CXXFLAGS / + # CXX_COMPILE_OPTIONS), for flags GCC rejects or warns about on C + self.cxx_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() @@ -642,6 +654,7 @@ class EsphomeCore: self.config_path = None self.build_path = None self.config = None + self.raw_config = None self.frontmatter = {} self.event_loop = _FakeEventLoop() self.task_counter = 0 @@ -650,6 +663,7 @@ class EsphomeCore: self.global_statements = [] self.platformio_libraries = {} self.build_flags = set() + self.cxx_build_flags = set() self.build_unflags = set() self.cpp_standard = None self.defines = set() @@ -718,12 +732,28 @@ class EsphomeCore: The hash is computed lazily and cached for performance. Uses sort_keys=True to ensure deterministic ordering. + + The hash must be reproducible across machines so the device builder + can compare a locally computed hash against the one a device + advertises. Machine-local data is kept out of the input: build_path + (which embeds ESPHOME_BUILD_PATH and OS path separators) is excluded, + and Path values are dumped relative to the config directory. """ if self._config_hash is None: from esphome import yaml_util from esphome.helpers import fnv1a_32bit_hash - config_str = yaml_util.dump(self.config, show_secrets=True, sort_keys=True) + config = dict(self.config) + if (esphome_conf := config.get(CONF_ESPHOME)) is not None: + esphome_conf = dict(esphome_conf) + esphome_conf.pop(CONF_BUILD_PATH, None) + config[CONF_ESPHOME] = esphome_conf + config_str = yaml_util.dump( + config, + show_secrets=True, + sort_keys=True, + relative_to=self.config_dir if self.config_path is not None else None, + ) self._config_hash = fnv1a_32bit_hash(config_str) return self._config_hash @@ -826,9 +856,38 @@ class EsphomeCore: def is_esp32(self): return self.target_platform == PLATFORM_ESP32 + @property + def is_rp2(self): + """Return True if the target platform is the RP2 chip family. + + Canonical umbrella check covering RP2040, RP2350, and any future + RP2-series chip. Mirrors :attr:`is_esp32` for the ESP32 family. + For variant-specific gating (RP2040 vs RP2350), use + ``rp2.get_rp2040_variant()`` or ``rp2.only_on_variant(...)`` from + the rp2 component — variant detection doesn't belong on ``CORE``. + """ + return self.target_platform == PLATFORM_RP2 + @property def is_rp2040(self): - return self.target_platform == PLATFORM_RP2040 + """Deprecated: use :attr:`is_rp2` for the family check, or + ``rp2.get_rp2040_variant() == rp2.VARIANT_RP2040`` for the + variant-specific check. Kept as an alias since pre-RP2350 + callers used it as a family check, identical to ``is_rp2``. + + Scheduled for removal in 2027.7.0. Logs a one-shot deprecation + warning per run (deduped via ``self.data`` so repeated reads in + the same invocation don't spam) to match the parallel + ``cv.only_on_rp2040`` shim. + """ + if not self.data.get(_IS_RP2040_DEPRECATED_KEY): + _LOGGER.warning( + "CORE.is_rp2040 is deprecated; use CORE.is_rp2 for the family " + "gate, or rp2.get_rp2040_variant() == rp2.VARIANT_RP2040 for " + "the variant-specific check. Removed in 2027.7.0." + ) + self.data[_IS_RP2040_DEPRECATED_KEY] = True + return self.is_rp2 @property def is_bk72xx(self): @@ -957,6 +1016,11 @@ class EsphomeCore: _LOGGER.debug("Adding build flag: %s", build_flag) return build_flag + def add_cxx_build_flag(self, build_flag: str) -> str: + self.cxx_build_flags.add(build_flag) + _LOGGER.debug("Adding C++ build flag: %s", build_flag) + 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 diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 11c7abe3f7..27c50ebb2a 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -86,14 +86,6 @@ std::string str_sprintf(const char *fmt, ...) { return str; } -// --- Value formatting helpers --- - -std::string value_accuracy_to_string(float value, int8_t accuracy_decimals) { - char buf[VALUE_ACCURACY_MAX_LEN]; - value_accuracy_to_buf(buf, value, accuracy_decimals); - return std::string(buf); -} - // --- Base64 helpers --- static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" diff --git a/esphome/core/alloc_helpers.h b/esphome/core/alloc_helpers.h index fe350886b7..1da3162333 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -94,14 +94,6 @@ std::string format_hex_pretty(const std::string &data, char separator = '.', boo /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); -// --- Value formatting helpers (allocating) --- - -/// Format a float value with accuracy decimals to a string. -/// @deprecated Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0. -__attribute__((deprecated("Allocates heap memory. Use value_accuracy_to_buf() instead. Removed in 2026.7.0."))) -std::string -value_accuracy_to_string(float value, int8_t accuracy_decimals); - // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. diff --git a/esphome/core/application.h b/esphome/core/application.h index 7c12a66b2c..76af514511 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -194,15 +194,6 @@ class Application { /// Buffer must be BUILD_TIME_STR_SIZE bytes (compile-time enforced) void get_build_time_string(std::span buffer); - /// Get the build time as a string (deprecated, use get_build_time_string() instead) - // Remove before 2026.7.0 - ESPDEPRECATED("Use get_build_time_string() instead. Removed in 2026.7.0", "2026.1.0") - std::string get_compilation_time() { - char buf[BUILD_TIME_STR_SIZE]; - this->get_build_time_string(buf); - return std::string(buf); - } - /// Get the cached time in milliseconds from when the current component started its loop execution inline uint32_t IRAM_ATTR HOT get_loop_component_start_time() const { return this->loop_component_start_time_; } diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index cf8b05a300..38e52e44cb 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -502,6 +502,9 @@ template class WaitUntilAction : public Action, public Co void stop() override { this->var_queue_.clear(); + // Tell any process_queue_() call further down the stack that the items it is + // still holding were cancelled + this->stop_generation_++; this->disable_loop(); } @@ -511,33 +514,57 @@ template class WaitUntilAction : public Action, public Co } protected: + using QueueItem = std::tuple, std::tuple>; + // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items bool process_queue_(uint32_t now) { - // Process each queued wait_until and remove completed ones - this->var_queue_.remove_if([&](auto &queued) { - auto start = std::get(queued); - auto timeout = std::get>(queued); - auto &var = std::get>(queued); + // Completed items run the rest of the action chain synchronously, and that chain + // can re-enter this same action (e.g. a script with mode: restart that executes + // itself) and add to or clear var_queue_. Iterating the member list directly would + // then corrupt it, so move it aside and iterate a local list instead. + std::list queue; + queue.swap(this->var_queue_); + std::list pending; + while (!queue.empty()) { + auto it = queue.begin(); + auto start = std::get(*it); + auto timeout = std::get>(*it); // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(var)) { - return false; + if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + pending.splice(pending.end(), queue, it); + continue; } - // Condition met or timed out - trigger next action - this->play_next_tuple_(var); - return true; - }); + // Condition met or timed out - trigger the next action. Keep the item in a local + // holder so its arguments stay valid while the chain runs, without any nested + // process_queue_() call being able to see (and fire) it again. + std::list completed; + completed.splice(completed.begin(), queue, it); + uint8_t generation = this->stop_generation_; + this->play_next_tuple_(std::get>(completed.front())); + if (generation != this->stop_generation_) { + // stop() ran inside the chain - the items still held locally were cancelled + pending.clear(); + break; + } + } + + // Re-entrant continuations may have enqueued new waits into var_queue_; put the + // older still-waiting items back in front of them to keep FIFO firing order + this->var_queue_.splice(this->var_queue_.begin(), pending); return !this->var_queue_.empty(); } Condition *condition_; - std::list, std::tuple>> var_queue_{}; + std::list var_queue_{}; + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + uint8_t stop_generation_{0}; }; template class UpdateComponentAction : public Action { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 7ef5ff50a5..281d7aaecd 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -85,24 +85,10 @@ void Component::setup() {} void Component::loop() {} -void Component::set_interval(const std::string &name, uint32_t interval, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_interval(this, name, interval, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_interval(const char *name, uint32_t interval, std::function &&f) { // NOLINT App.scheduler.set_interval(this, name, interval, std::move(f)); } -bool Component::cancel_interval(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_interval(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_interval(const char *name) { // NOLINT return App.scheduler.cancel_interval(this, name); } @@ -137,24 +123,10 @@ bool Component::cancel_retry(const char *name) { // NOLINT #pragma GCC diagnostic pop } -void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, timeout, std::move(f)); -#pragma GCC diagnostic pop -} - void Component::set_timeout(const char *name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } -bool Component::cancel_timeout(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} - bool Component::cancel_timeout(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } @@ -319,21 +291,9 @@ void Component::reset_to_construction_state() { void Component::defer(std::function &&f) { // NOLINT App.scheduler.set_timeout(this, static_cast(nullptr), 0, std::move(f)); } -bool Component::cancel_defer(const std::string &name) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return App.scheduler.cancel_timeout(this, name); -#pragma GCC diagnostic pop -} bool Component::cancel_defer(const char *name) { // NOLINT return App.scheduler.cancel_timeout(this, name); } -void Component::defer(const std::string &name, std::function &&f) { // NOLINT -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - App.scheduler.set_timeout(this, name, 0, std::move(f)); -#pragma GCC diagnostic pop -} void Component::defer(const char *name, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, 0, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 299a5f72ea..70a051ca0b 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -33,6 +33,8 @@ class RuntimeStatsCollector; */ namespace setup_priority { +/// For power supply components that must be on before buses like i2c can work. +inline constexpr float POWER = 1200.0f; /// For communication buses like i2c/spi inline constexpr float BUS = 1000.0f; /// For components that represent GPIO pins like PCF8573 @@ -357,9 +359,9 @@ class Component { /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); - /** Set an interval function with a unique name. Empty name means no cancelling possible. + /** Set an interval function with a const char* name. Empty name means no cancelling possible. * - * This will call f every interval ms. Can be cancelled via CancelInterval(). + * This will call f every interval ms. Can be cancelled via cancel_interval(). * Similar to javascript's setInterval(). * * IMPORTANT NOTE: @@ -372,18 +374,6 @@ class Component { * * Note also that the first call to f will not happen immediately, but after a random delay. This is * intended to prevent many interval functions from being called at the same time. - * - * @param name The identifier for this interval function. - * @param interval The interval in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_interval() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(const std::string &name, uint32_t interval, std::function &&f); // NOLINT - - /** Set an interval function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -391,7 +381,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the scheduled task * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The identifier for this interval function (must have static lifetime) * @param interval The interval in ms @@ -416,12 +406,9 @@ class Component { * @param name The identifier for this interval function. * @return Whether an interval functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(const std::string &name); // NOLINT - bool cancel_interval(const char *name); // NOLINT - bool cancel_interval(uint32_t id); // NOLINT - bool cancel_interval(InternalSchedulerID id); // NOLINT + bool cancel_interval(const char *name); // NOLINT + bool cancel_interval(uint32_t id); // NOLINT + bool cancel_interval(InternalSchedulerID id); // NOLINT /// @deprecated set_retry is deprecated. Use set_timeout or set_interval instead. Removed in 2026.8.0. // Remove before 2026.8.0 @@ -458,25 +445,13 @@ class Component { ESPDEPRECATED("cancel_retry is deprecated and will be removed in 2026.8.0.", "2026.2.0") bool cancel_retry(uint32_t id); // NOLINT - /** Set a timeout function with a unique name. + /** Set a timeout function with a const char* name. * * Similar to javascript's setTimeout(). Empty name means no cancelling possible. * * IMPORTANT: Do not rely on this having correct timing. This is only called from - * loop() and therefore can be significantly delay. If you need exact timing please + * loop() and therefore can be significantly delayed. If you need exact timing please * use hardware timers. - * - * @param name The identifier for this timeout function. - * @param timeout The timeout in ms. - * @param f The function (or lambda) that should be called - * - * @see cancel_timeout() - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(const std::string &name, uint32_t timeout, std::function &&f); // NOLINT - - /** Set a timeout function with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. * This means the name should be: @@ -484,7 +459,9 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the timeout duration * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. + * + * @see cancel_timeout() * * @param name The identifier for this timeout function (must have static lifetime) * @param timeout The timeout in ms @@ -509,25 +486,13 @@ class Component { * @param name The identifier for this timeout function. * @return Whether a timeout functions was deleted. */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(const std::string &name); // NOLINT - bool cancel_timeout(const char *name); // NOLINT - bool cancel_timeout(uint32_t id); // NOLINT - bool cancel_timeout(InternalSchedulerID id); // NOLINT - - /** Defer a callback to the next loop() call. - * - * If name is specified and a defer() object with the same name exists, the old one is first removed. - * - * @param name The name of the defer function. - * @param f The callback. - */ - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - void defer(const std::string &name, std::function &&f); // NOLINT + bool cancel_timeout(const char *name); // NOLINT + bool cancel_timeout(uint32_t id); // NOLINT + bool cancel_timeout(InternalSchedulerID id); // NOLINT /** Defer a callback to the next loop() call with a const char* name. + * + * If name is specified and a defer() object with the same name exists, the old one is first removed. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the deferred task. * This means the name should be: @@ -535,7 +500,7 @@ class Component { * - A static const char* variable * - A pointer with lifetime >= the deferred execution * - * For dynamic strings, use the std::string overload instead. + * For dynamic names, use the uint32_t id overload instead. * * @param name The name of the defer function (must have static lifetime) * @param f The callback @@ -549,11 +514,8 @@ class Component { void defer(uint32_t id, std::function &&f); // NOLINT /// Cancel a defer callback using the specified name, name must not be empty. - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_defer(const std::string &name); // NOLINT - bool cancel_defer(const char *name); // NOLINT - bool cancel_defer(uint32_t id); // NOLINT + bool cancel_defer(const char *name); // NOLINT + bool cancel_defer(uint32_t id); // NOLINT void status_clear_warning_slow_path_(); void status_clear_error_slow_path_(); diff --git a/esphome/core/config.py b/esphome/core/config.py index 59c96035b8..6b24a55487 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -26,6 +26,7 @@ from esphome.const import ( CONF_INCLUDES, CONF_INCLUDES_C, CONF_LIBRARIES, + CONF_MERGE_WARNINGS, CONF_MIN_VERSION, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, @@ -283,14 +284,24 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_COMMENT): cv.All( cv.string, cv.ByteLength(max=COMMENT_MAX_LEN) ), - cv.Required(CONF_BUILD_PATH): cv.string, - cv.Optional(CONF_PLATFORMIO_OPTIONS, default={}): cv.Schema( + cv.Required(CONF_BUILD_PATH, visibility=cv.Visibility.YAML_ONLY): cv.string, + cv.Optional( + CONF_PLATFORMIO_OPTIONS, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.Any([cv.string], cv.string), } ), - cv.Optional(CONF_BUILD_FLAGS, default=[]): cv.ensure_list(cv.string_strict), - cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + cv.Optional( + CONF_BUILD_FLAGS, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_ENVIRONMENT_VARIABLES, + default={}, + visibility=cv.Visibility.YAML_ONLY, + ): cv.Schema( { cv.string_strict: cv.string, } @@ -312,11 +323,20 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LoopTrigger), } ), - cv.Optional(CONF_INCLUDES, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_INCLUDES_C, default=[]): cv.ensure_list(valid_include), - cv.Optional(CONF_LIBRARIES, default=[]): cv.ensure_list(cv.string_strict), + cv.Optional( + CONF_INCLUDES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_INCLUDES_C, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(valid_include), + cv.Optional( + CONF_LIBRARIES, default=[], visibility=cv.Visibility.YAML_ONLY + ): cv.ensure_list(cv.string_strict), cv.Optional(CONF_NAME_ADD_MAC_SUFFIX, default=False): cv.boolean, - cv.Optional(CONF_DEBUG_SCHEDULER, default=False): cv.boolean, + cv.Optional(CONF_MERGE_WARNINGS, default=True): cv.boolean, + cv.Optional( + CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -336,11 +356,15 @@ CONFIG_SCHEMA = cv.All( ), } ), - cv.Optional(CONF_MIN_VERSION, default=ESPHOME_VERSION): cv.All( - cv.version_number, cv.validate_esphome_version - ), cv.Optional( - CONF_COMPILE_PROCESS_LIMIT, default=_compile_process_limit_default + CONF_MIN_VERSION, + default=ESPHOME_VERSION, + visibility=cv.Visibility.ADVANCED, + ): cv.All(cv.version_number, cv.validate_esphome_version), + cv.Optional( + CONF_COMPILE_PROCESS_LIMIT, + default=_compile_process_limit_default, + visibility=cv.Visibility.ADVANCED, ): cv.int_range(min=1, max=get_usable_cpu_count()), cv.Optional(CONF_AREAS, default=[]): cv.ensure_list(AREA_SCHEMA), cv.Optional(CONF_DEVICES, default=[]): cv.ensure_list(DEVICE_SCHEMA), @@ -721,6 +745,15 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + # C++20 deprecated ++/--, compound assignment, and chained assignment on + # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. + # C++23 (P2327R1) removed the deprecation for compound assignment, so the + # warning flags patterns that are valid again under newer standards. + # C++-only flag: GCC warns when it is passed on a C compile, hence + # add_cxx_build_flag. Skipped for host builds, where the compiler may be + # clang, which does not know this GCC option. + if not CORE.is_host: + cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") @@ -857,8 +890,8 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "wake/wake_esp8266.cpp": { PlatformFramework.ESP8266_ARDUINO, }, - "wake/wake_rp2040.cpp": { - PlatformFramework.RP2040_ARDUINO, + "wake/wake_rp2.cpp": { + PlatformFramework.RP2_ARDUINO, }, "wake/wake_host.cpp": { PlatformFramework.HOST_NATIVE, diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 410858f904..5c5fc5e8b9 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -19,13 +19,13 @@ // Threading model for static analysis. Match what the real codegen picks per // platform (see esphome/components//__init__.py ThreadModel.*): -// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_ESP8266 / USE_RP2 / USE_NRF52 → SINGLE // USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS // everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS // Without this the clang-tidy envs end up with USE_ // + MULTI_ATOMICS simultaneously, a combination that can never occur in a // real build. -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_NRF52) #define ESPHOME_THREAD_SINGLE #elif defined(USE_BK72XX) #define ESPHOME_THREAD_MULTI_NO_ATOMICS @@ -89,6 +89,7 @@ #define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL +#define USE_LVGL_ANIMATION #define USE_LVGL_ANIMIMG #define USE_LVGL_ARC #define USE_LVGL_BINARY_SENSOR @@ -148,10 +149,12 @@ #define USE_NEXTION_TRIGGER_CUSTOM_TEXT_SENSOR #define USE_NEXTION_WAVEFORM #define USE_NUMBER +#define USE_OTA_STATE_LISTENER #define USE_OUTPUT #define USE_OUTPUT_FLOAT_POWER_SCALING #define USE_POWER_SUPPLY #define USE_PREFERENCES_SYNC_EVERY_LOOP +#define USE_PROVISIONING #define USE_QR_CODE #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 @@ -211,7 +214,6 @@ #define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_PASSWORD -#define USE_OTA_STATE_LISTENER #define USE_OTA_VERSION 2 #define USE_TIME_TIMEZONE #define USE_WIFI @@ -227,7 +229,7 @@ #endif // Platforms with native 64-bit time sources (no rollover tracking needed) -#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_ZEPHYR) || defined(USE_RP2) #define USE_NATIVE_64BIT_TIME #endif @@ -239,8 +241,12 @@ #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_OTA_ROLLBACK #define USE_OTA_SIGNED_VERIFICATION +#define USE_OTA_DOWNGRADE_PROTECTION #define USE_ESP32_MIN_CHIP_REVISION_SET +#define USE_ESP32_RTC_PREFERENCES #define USE_ESP32_SRAM1_AS_IRAM +#define USE_ESPNOW +#define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 #define USE_BLUETOOTH_PROXY #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 @@ -292,14 +298,17 @@ #define USE_VOICE_ASSISTANT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP #define USE_WEBSERVER_SORTING +#define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS @@ -312,6 +321,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 @@ -326,6 +336,8 @@ #define USE_ETHERNET_JL1101 #define USE_ETHERNET_KSZ8081 #define USE_ETHERNET_LAN8670 +#define USE_ETHERNET_GENERIC +#define USE_ETHERNET_YT8531 #define USE_ETHERNET_SPI #define USE_ETHERNET_SPI_POLLING_SUPPORT #define USE_ETHERNET_OPENETH @@ -397,12 +409,16 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #endif -#ifdef USE_RP2040 +// USE_RP2 is the canonical platform define for the RP2 chip family. The +// rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias +// for external custom components that may still test for it. +#ifdef USE_RP2 #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 0) -#define USE_RP2040_CRASH_HANDLER +#define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C #define USE_LOGGER_USB_CDC @@ -424,6 +440,7 @@ #define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH +#define USE_WEBSERVER_AUTH_DIGEST #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index a47af1dd93..32135860bb 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -147,13 +147,6 @@ std::string EntityBase::get_icon() const { } #endif // !USE_ESP8266 -// Entity Object ID - computed on-demand from name -std::string EntityBase::get_object_id() const { - char buf[OBJECT_ID_MAX_LEN]; - size_t len = this->write_object_id_to(buf, sizeof(buf)); - return std::string(buf, len); -} - // Calculate Object ID Hash directly from name using snake_case + sanitize void EntityBase::calc_object_id_() { this->object_id_hash_ = fnv1_hash_object_id(this->name_.c_str(), this->name_.size()); diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2726a92c97..4f708209d4 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -73,18 +73,6 @@ class EntityBase { // Get whether this Entity has its own name or it should use the device friendly_name. bool has_own_name() const { return this->flags_.has_own_name; } - // Get the sanitized name of this Entity as an ID. - // Deprecated: object_id mangles names and all object_id methods are planned for removal. - // See https://github.com/esphome/backlog/issues/76 - // Now is the time to stop using object_id entirely. If you still need it temporarily, - // use get_object_id_to() which will remain available longer but will also eventually be removed. - ESPDEPRECATED("object_id mangles names and all object_id methods are planned for removal " - "(see https://github.com/esphome/backlog/issues/76). " - "Now is the time to stop using object_id. If still needed, use get_object_id_to() " - "which will remain available longer. get_object_id() will be removed in 2026.7.0", - "2025.12.0") - std::string get_object_id() const; - // Get the unique Object ID of this Entity uint32_t get_object_id_hash() const { return this->object_id_hash_; } diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index ff60260280..38c7f3ca43 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -337,7 +337,7 @@ def get_base_entity_object_id( This function calculates what object_id_c_str_ should be set to in C++. - The C++ EntityBase::get_object_id() (entity_base.cpp lines 38-49) works as: + The C++ EntityBase::write_object_id_to() (entity_base.cpp) works as: - If !has_own_name && is_name_add_mac_suffix_enabled(): return str_sanitize(str_snake_case(App.get_friendly_name())) // Dynamic - Else: diff --git a/esphome/core/gpio.h b/esphome/core/gpio.h index f2f85e18bc..43db3b7c0c 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,8 +1,6 @@ #pragma once #include #include -#include -#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -80,11 +78,6 @@ class GPIOPin { /// which may exceed len-1 if truncation occurred (snprintf semantics) virtual size_t dump_summary(char *buffer, size_t len) const; - /// Get a summary of this pin as a string. - /// @deprecated Use dump_summary(char*, size_t) instead. Will be removed in 2026.7.0. - ESPDEPRECATED("Override dump_summary(char*, size_t) instead. Will be removed in 2026.7.0.", "2026.1.0") - virtual std::string dump_summary() const; - virtual bool is_internal() { return false; } }; @@ -122,28 +115,14 @@ class InternalGPIOPin : public GPIOPin { virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; }; -// Inline default implementations for GPIOPin virtual methods. -// These provide bridge functionality for backwards compatibility with external components. - -// Default implementation bridges to old std::string method for backwards compatibility. +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len == 0) - return 0; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - std::string s = this->dump_summary(); -#pragma GCC diagnostic pop - size_t copy_len = std::min(s.size(), len - 1); - memcpy(buffer, s.c_str(), copy_len); - buffer[copy_len] = '\0'; - return s.size(); // Return would-be length (snprintf semantics) + if (len > 0) + buffer[0] = '\0'; + return 0; } -// Default implementation returns empty string. -// External components should override this if they haven't migrated to buffer-based version. -// Remove before 2026.7.0 -inline std::string GPIOPin::dump_summary() const { return {}; } - // Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { char buffer[GPIO_SUMMARY_MAX_LEN]; diff --git a/esphome/core/hal.h b/esphome/core/hal.h index b44a422836..4c5a19c6d1 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -19,8 +19,8 @@ #include "esphome/components/esp8266/hal.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/hal.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/hal.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/hal.h" #elif defined(USE_HOST) #include "esphome/components/host/hal.h" #elif defined(USE_ZEPHYR) diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 112dde7c45..a7b63643a4 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -669,11 +669,11 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, if (delta == 0) { hue = 0; } else if (max_color_value == red) { - hue = int(fmod(((60 * ((green - blue) / delta)) + 360), 360)); + hue = int(fmodf((60.0f * ((green - blue) / delta)) + 360.0f, 360.0f)); } else if (max_color_value == green) { - hue = int(fmod(((60 * ((blue - red) / delta)) + 120), 360)); + hue = int(fmodf((60.0f * ((blue - red) / delta)) + 120.0f, 360.0f)); } else if (max_color_value == blue) { - hue = int(fmod(((60 * ((red - green) / delta)) + 240), 360)); + hue = int(fmodf((60.0f * ((red - green) / delta)) + 240.0f, 360.0f)); } if (max_color_value == 0) { @@ -686,8 +686,8 @@ void rgb_to_hsv(float red, float green, float blue, int &hue, float &saturation, } void hsv_to_rgb(int hue, float saturation, float value, float &red, float &green, float &blue) { float chroma = value * saturation; - float hue_prime = fmod(hue / 60.0, 6); - float intermediate = chroma * (1 - fabs(fmod(hue_prime, 2) - 1)); + float hue_prime = fmodf(hue / 60.0f, 6.0f); + float intermediate = chroma * (1.0f - fabsf(fmodf(hue_prime, 2.0f) - 1.0f)); float delta = value - chroma; if (0 <= hue_prime && hue_prime < 1) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 07bcb7a74f..e862d015da 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -33,7 +33,7 @@ #include #endif -#ifdef USE_RP2040 +#ifdef USE_RP2 #include #endif @@ -184,8 +184,10 @@ template class SmallInlineBuffer { SmallInlineBuffer(const SmallInlineBuffer &) = delete; SmallInlineBuffer &operator=(const SmallInlineBuffer &) = delete; - /// Set buffer contents, allocating heap if needed - void set(const uint8_t *src, size_t size) { + /// Resize to `size` bytes of (uninitialized) storage and return a writable pointer to fill. + /// Allocates heap only when `size` exceeds the inline capacity. Use this when the contents are + /// built in place (e.g. assembling a frame and appending a checksum) to avoid a staging copy. + uint8_t *init(size_t size) { // Free existing heap allocation if switching from heap to inline or different heap size if (!this->is_inline_() && (size <= InlineSize || size != this->len_)) { delete[] this->heap_; @@ -196,9 +198,12 @@ template class SmallInlineBuffer { this->heap_ = new uint8_t[size]; // NOLINT(cppcoreguidelines-owning-memory) } this->len_ = size; - memcpy(this->data(), src, size); + return this->data(); } + /// Set buffer contents, allocating heap if needed + void set(const uint8_t *src, size_t size) { memcpy(this->init(size), src, size); } + uint8_t *data() { return this->is_inline_() ? this->inline_ : this->heap_; } const uint8_t *data() const { return this->is_inline_() ? this->inline_ : this->heap_; } size_t size() const { return this->len_; } @@ -683,6 +688,15 @@ template class FixedVector { T &back() { return data_[size_ - 1]; } const T &back() const { return data_[size_ - 1]; } + /// Remove the last element in place (no reallocation, keeps capacity) + /// Caller must ensure vector is not empty (size() > 0) + void pop_back() { + if constexpr (!std::is_trivially_destructible::value) { + data_[size_ - 1].~T(); + } + size_--; + } + size_t size() const { return size_; } bool empty() const { return size_ == 0; } size_t capacity() const { return capacity_; } @@ -1886,7 +1900,7 @@ class Mutex { Mutex(const Mutex &) = delete; Mutex &operator=(const Mutex &) = delete; -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // Single-threaded platforms: inline no-ops so the compiler eliminates all call overhead. Mutex() = default; ~Mutex() = default; @@ -1955,7 +1969,7 @@ class InterruptLock { ~InterruptLock(); protected: -#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_ZEPHYR) +#if defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_ZEPHYR) uint32_t state_; #endif }; @@ -1973,7 +1987,7 @@ class LwIPLock { LwIPLock(const LwIPLock &) = delete; LwIPLock &operator=(const LwIPLock &) = delete; -#if defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_ESP32) || defined(USE_RP2) // Platforms with potential lwIP core locking — out-of-line implementations in helpers.cpp LwIPLock(); ~LwIPLock(); @@ -2123,7 +2137,7 @@ template class RAMAllocator { auto max_external = this->flags_ & ALLOC_EXTERNAL ? heap_caps_get_free_size(MALLOC_CAP_8BIT | MALLOC_CAP_SPIRAM) : 0; return max_internal + max_external; -#elif defined(USE_RP2040) +#elif defined(USE_RP2) return ::rp2040.getFreeHeap(); #elif defined(USE_LIBRETINY) return lt_heap_get_free(); 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/preference_backend.h b/esphome/core/preference_backend.h index 431de205af..34bf84409d 100644 --- a/esphome/core/preference_backend.h +++ b/esphome/core/preference_backend.h @@ -12,8 +12,8 @@ #include "esphome/components/esp32/preference_backend.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preference_backend.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preference_backend.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preference_backend.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preference_backend.h" #elif defined(USE_HOST) @@ -24,7 +24,7 @@ namespace esphome { -#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \ +#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \ !defined(USE_HOST) && !(defined(USE_ZEPHYR) && defined(CONFIG_SETTINGS)) // Stub for static analysis when no platform is defined. struct PreferenceBackend { diff --git a/esphome/core/preferences.h b/esphome/core/preferences.h index 64a0a927e6..1efce5af51 100644 --- a/esphome/core/preferences.h +++ b/esphome/core/preferences.h @@ -9,8 +9,8 @@ #include "esphome/components/esp32/preferences.h" #elif defined(USE_ESP8266) #include "esphome/components/esp8266/preferences.h" -#elif defined(USE_RP2040) -#include "esphome/components/rp2040/preferences.h" +#elif defined(USE_RP2) +#include "esphome/components/rp2/preferences.h" #elif defined(USE_LIBRETINY) #include "esphome/components/libretiny/preferences.h" #elif defined(USE_HOST) diff --git a/esphome/core/preferences_rtc.h b/esphome/core/preferences_rtc.h new file mode 100644 index 0000000000..30b004f994 --- /dev/null +++ b/esphome/core/preferences_rtc.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +namespace esphome { + +// Shared storage format for word-addressable preference backends. +// +// Several platforms persist preferences as a buffer of 32-bit words followed by a +// single checksum word, seeded with the preference's `type` (its hashed key). This +// format is used for RTC user memory (ESP8266, ESP32) and for the ESP8266 +// flash-emulation buffer. The helpers here are platform independent; each backend +// supplies its own word read/write primitives and offset allocation. + +/// Round a byte count up to whole 32-bit words. +inline size_t rtc_pref_bytes_to_words(size_t bytes) { return (bytes + 3) / 4; } + +/// Compute the integrity checksum over [first, last), seeded with `type`. +/// Iterates over 32-bit words; the result is stored as the trailing word of a record. +/// (Not a true CRC -- it XORs each word after a Fibonacci-hash multiply -- but the +/// algorithm is kept as-is for compatibility with records written by old firmware.) +template uint32_t rtc_pref_calculate_checksum(It first, It last, uint32_t type) { + uint32_t checksum = type; + while (first != last) { + // UINT32_C keeps the multiply wrapping at 32 bits regardless of the width of + // unsigned long, so 64-bit host builds compute the same value as the devices. + checksum ^= (*first++ * UINT32_C(2654435769)) >> 1; + } + return checksum; +} + +/// Encode `len` data bytes into `buffer` (length_words data words + 1 trailing checksum word). +/// `buffer` must have capacity for at least `length_words + 1` words. Trailing padding in +/// the final data word is zeroed so the checksum is deterministic. +inline void rtc_pref_encode(uint32_t *buffer, uint32_t type, uint8_t length_words, const uint8_t *data, size_t len) { + memset(buffer, 0, (static_cast(length_words) + 1) * sizeof(uint32_t)); + memcpy(buffer, data, len); + buffer[length_words] = rtc_pref_calculate_checksum(buffer, buffer + length_words, type); +} + +/// Verify the checksum of a record held in `buffer` (length_words data words + 1 checksum +/// word) and, on success, copy `len` bytes out to `data`. Returns false on checksum mismatch +/// (e.g. the record was never written or RTC memory holds power-on garbage). +inline bool rtc_pref_decode(const uint32_t *buffer, uint32_t type, uint8_t length_words, uint8_t *data, size_t len) { + if (buffer[length_words] != rtc_pref_calculate_checksum(buffer, buffer + length_words, type)) { + return false; + } + memcpy(data, buffer, len); + return true; +} + +} // namespace esphome diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 15bb9ea239..8449cba5e8 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -254,30 +254,16 @@ void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - timeout, std::move(func)); -} void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } -bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT); -} bool HOT Scheduler::cancel_timeout(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT); } bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } -void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function &&func) { - this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), - interval, std::move(func)); -} - void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, @@ -287,9 +273,6 @@ void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t int this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } -bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) { - return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL); -} bool HOT Scheduler::cancel_interval(Component *component, const char *name) { return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL); } @@ -373,7 +356,7 @@ void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, } #endif - if (backoff_increase_factor < 0.0001) { + if (backoff_increase_factor < 0.0001f) { ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor, (name_type == NameType::STATIC_STRING && static_name) ? static_name : ""); backoff_increase_factor = 1; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 378c0fb94b..c7743e5b2a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -31,11 +31,6 @@ class Scheduler { template friend class DelayAction; public: - // std::string overload - deprecated, use const char* or uint32_t instead - // Remove before 2026.7.0 - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function &&func); - /** Set a timeout with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -53,8 +48,6 @@ class Scheduler { static_cast(id), timeout, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_timeout(Component *component, const std::string &name); bool cancel_timeout(Component *component, const char *name); bool cancel_timeout(Component *component, uint32_t id); bool cancel_timeout(Component *component, InternalSchedulerID id) { @@ -62,9 +55,6 @@ class Scheduler { SchedulerItem::TIMEOUT); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(Component *component, const std::string &name, uint32_t interval, std::function &&func); - /** Set an interval with a const char* name. * * IMPORTANT: The provided name pointer must remain valid for the lifetime of the scheduler item. @@ -82,8 +72,6 @@ class Scheduler { static_cast(id), interval, std::move(func)); } - ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - bool cancel_interval(Component *component, const std::string &name); bool cancel_interval(Component *component, const char *name); bool cancel_interval(Component *component, uint32_t id); bool cancel_interval(Component *component, InternalSchedulerID id) { @@ -396,8 +384,8 @@ class Scheduler { inline bool HOT names_match_static_(const char *name1, const char *name2) const { // Check pointer equality first (common for static strings), then string contents // The core ESPHome codebase uses static strings (const char*) for component names, - // making pointer comparison effective. The std::string overloads exist only for - // compatibility with external components but are rarely used in practice. + // making pointer comparison effective. The strcmp fallback covers distinct pointers + // with identical content (e.g. names built into separate static buffers). return (name1 != nullptr && name2 != nullptr) && ((name1 == name2) || (strcmp(name1, name2) == 0)); } diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 5a5d27ceff..a48e52fb73 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -18,7 +18,7 @@ namespace esphome { // === Wake flag for ESP8266/RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_RP2) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern volatile bool g_main_loop_woke; #endif @@ -65,8 +65,8 @@ __attribute__((always_inline)) inline bool wake_request_take() { #include "esphome/core/wake/wake_freertos.h" #elif defined(USE_ESP8266) #include "esphome/core/wake/wake_esp8266.h" -#elif defined(USE_RP2040) -#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_RP2) +#include "esphome/core/wake/wake_rp2.h" #elif defined(USE_HOST) #include "esphome/core/wake/wake_host.h" #elif defined(USE_ZEPHYR) 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/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2.cpp similarity index 97% rename from esphome/core/wake/wake_rp2040.cpp rename to esphome/core/wake/wake_rp2.cpp index bdcbb1ad00..101c87c818 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2.cpp @@ -1,6 +1,6 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" #include "esphome/core/wake.h" @@ -59,4 +59,4 @@ void wakeable_delay(uint32_t ms) { } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2.h similarity index 88% rename from esphome/core/wake/wake_rp2040.h rename to esphome/core/wake/wake_rp2.h index ea1242f535..715e5aca0c 100644 --- a/esphome/core/wake/wake_rp2040.h +++ b/esphome/core/wake/wake_rp2.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/hal.h" @@ -21,11 +21,11 @@ inline void wake_loop_any_context() { inline void wake_loop_threadsafe() { wake_loop_any_context(); } -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2.cpp. namespace internal { void wakeable_delay(uint32_t ms); } // namespace internal } // namespace esphome -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 582b8fc74d..6bcf4eed77 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -699,6 +699,15 @@ def add_build_flag(build_flag: str): CORE.add_build_flag(build_flag) +def add_cxx_build_flag(build_flag: str) -> None: + """Add a global build flag that applies to C++ compiles only. + + Use for flags GCC rejects or warns about when passed on C compiles + (e.g. ``-Wno-volatile``). + """ + CORE.add_cxx_build_flag(build_flag) + + def add_build_unflag(build_unflag: str) -> None: """Add a global build unflag to the compiler flags.""" CORE.add_build_unflag(build_unflag) diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py deleted file mode 100644 index 9cadc442ef..0000000000 --- a/esphome/dashboard/const.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import sys - -from esphome.enum import StrEnum - - -class DashboardEvent(StrEnum): - """Dashboard WebSocket event types.""" - - # Server -> Client events (backend sends to frontend) - ENTRY_ADDED = "entry_added" - ENTRY_REMOVED = "entry_removed" - ENTRY_UPDATED = "entry_updated" - ENTRY_STATE_CHANGED = "entry_state_changed" - IMPORTABLE_DEVICE_ADDED = "importable_device_added" - IMPORTABLE_DEVICE_REMOVED = "importable_device_removed" - INITIAL_STATE = "initial_state" # Sent on WebSocket connection - PONG = "pong" # Response to client ping - - # Client -> Server events (frontend sends to backend) - PING = "ping" # WebSocket keepalive from client - REFRESH = "refresh" # Force backend to poll for changes - - -MAX_EXECUTOR_WORKERS = 48 - - -SENTINEL = object() - -ESPHOME_COMMAND = [sys.executable, "-m", "esphome"] -DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"] diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py deleted file mode 100644 index b9ec56cd00..0000000000 --- a/esphome/dashboard/core.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Coroutine -import contextlib -from dataclasses import dataclass -from functools import partial -import json -import logging -import threading -from typing import Any - -from esphome.storage_json import ignored_devices_storage_path - -from ..zeroconf import DiscoveredImport -from .const import DashboardEvent -from .dns import DNSCache -from .entries import DashboardEntries -from .settings import DashboardSettings -from .status.mdns import MDNSStatus -from .status.ping import PingStatus - -_LOGGER = logging.getLogger(__name__) - -IGNORED_DEVICES_STORAGE_PATH = "ignored-devices.json" - -MDNS_BOOTSTRAP_TIME = 7.5 - - -@dataclass -class Event: - """Dashboard Event.""" - - event_type: DashboardEvent - data: dict[str, Any] - - -class EventBus: - """Dashboard event bus.""" - - def __init__(self) -> None: - """Initialize the Dashboard event bus.""" - self._listeners: dict[DashboardEvent, set[Callable[[Event], None]]] = {} - - def async_add_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> Callable[[], None]: - """Add a listener to the event bus.""" - self._listeners.setdefault(event_type, set()).add(listener) - return partial(self._async_remove_listener, event_type, listener) - - def _async_remove_listener( - self, event_type: DashboardEvent, listener: Callable[[Event], None] - ) -> None: - """Remove a listener from the event bus.""" - self._listeners[event_type].discard(listener) - - def async_fire( - self, event_type: DashboardEvent, event_data: dict[str, Any] - ) -> None: - """Fire an event.""" - event = Event(event_type, event_data) - - _LOGGER.debug("Firing event: %s", event) - - for listener in self._listeners.get(event_type, set()): - listener(event) - - -class ESPHomeDashboard: - """Class that represents the dashboard.""" - - __slots__ = ( - "bus", - "entries", - "loop", - "import_result", - "stop_event", - "ping_request", - "mqtt_ping_request", - "mdns_status", - "settings", - "dns_cache", - "_background_tasks", - "ignored_devices", - "_ping_status_task", - ) - - def __init__(self) -> None: - """Initialize the ESPHomeDashboard.""" - self.bus = EventBus() - self.entries: DashboardEntries | None = None - self.loop: asyncio.AbstractEventLoop | None = None - self.import_result: dict[str, DiscoveredImport] = {} - self.stop_event = threading.Event() - self.ping_request: asyncio.Event | None = None - self.mqtt_ping_request = threading.Event() - self.mdns_status: MDNSStatus | None = None - self.settings = DashboardSettings() - self.dns_cache = DNSCache() - self._background_tasks: set[asyncio.Task] = set() - self.ignored_devices: set[str] = set() - self._ping_status_task: asyncio.Task | None = None - - async def async_setup(self) -> None: - """Setup the dashboard.""" - self.loop = asyncio.get_running_loop() - self.ping_request = asyncio.Event() - self.entries = DashboardEntries(self) - await self.loop.run_in_executor(None, self.load_ignored_devices) - - def load_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - try: - with storage_path.open("r", encoding="utf-8") as f_handle: - data = json.load(f_handle) - self.ignored_devices = set(data.get("ignored_devices", set())) - except FileNotFoundError: - pass - - def save_ignored_devices(self) -> None: - storage_path = ignored_devices_storage_path() - with storage_path.open("w", encoding="utf-8") as f_handle: - json.dump( - {"ignored_devices": sorted(self.ignored_devices)}, indent=2, fp=f_handle - ) - - def _async_start_ping_status(self, ping_status: PingStatus) -> None: - self._ping_status_task = asyncio.create_task(ping_status.async_run()) - - async def async_run(self) -> None: - """Run the dashboard.""" - settings = self.settings - mdns_task: asyncio.Task | None = None - await self.entries.async_update_entries() - - mdns_status = MDNSStatus(self) - ping_status = PingStatus(self) - start_ping_timer: asyncio.TimerHandle | None = None - - self.mdns_status = mdns_status - if mdns_status.async_setup(): - mdns_task = asyncio.create_task(mdns_status.async_run()) - # Start ping MDNS_BOOTSTRAP_TIME seconds after startup to ensure - # MDNS has had a chance to resolve the devices - start_ping_timer = self.loop.call_later( - MDNS_BOOTSTRAP_TIME, self._async_start_ping_status, ping_status - ) - else: - # If mDNS is not available, start the ping status immediately - self._async_start_ping_status(ping_status) - - if settings.status_use_mqtt: - from .status.mqtt import MqttStatusThread - - status_thread_mqtt = MqttStatusThread(self) - status_thread_mqtt.start() - - try: - await asyncio.Event().wait() - finally: - _LOGGER.info("Shutting down...") - self.stop_event.set() - self.ping_request.set() - if start_ping_timer: - start_ping_timer.cancel() - if self._ping_status_task: - self._ping_status_task.cancel() - self._ping_status_task = None - if mdns_task: - mdns_task.cancel() - if settings.status_use_mqtt: - status_thread_mqtt.join() - self.mqtt_ping_request.set() - for task in self._background_tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - await asyncio.sleep(0) - - def async_create_background_task( - self, coro: Coroutine[Any, Any, Any] - ) -> asyncio.Task: - """Create a background task.""" - task = self.loop.create_task(coro) - task.add_done_callback(self._background_tasks.discard) - return task - - -DASHBOARD = ESPHomeDashboard() diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py deleted file mode 100644 index 7fc21f8a44..0000000000 --- a/esphome/dashboard/dashboard.py +++ /dev/null @@ -1,153 +0,0 @@ -from __future__ import annotations - -import asyncio -from asyncio import events -from concurrent.futures import ThreadPoolExecutor -import contextlib -import logging -import os -from pathlib import Path -import socket -import threading -from time import monotonic -import traceback -from typing import Any - -from esphome.storage_json import EsphomeStorageJSON, esphome_storage_path - -from .const import MAX_EXECUTOR_WORKERS -from .core import DASHBOARD -from .web_server import make_app, start_web_server - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -settings = DASHBOARD.settings - - -def can_use_pidfd() -> bool: - """Check if pidfd_open is available. - - Back ported from cpython 3.12 - """ - if not hasattr(os, "pidfd_open"): - return False - try: - pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) - except OSError: - # blocked by security policy like SECCOMP - return False - return True - - -class DashboardEventLoopPolicy(asyncio.DefaultEventLoopPolicy): - """Event loop policy for Home Assistant.""" - - def __init__(self, debug: bool) -> None: - """Init the event loop policy.""" - super().__init__() - self.debug = debug - self._watcher: asyncio.AbstractChildWatcher | None = None - - def _init_watcher(self) -> None: - """Initialize the watcher for child processes. - - Back ported from cpython 3.12 - """ - with events._lock: # type: ignore[attr-defined] # pylint: disable=protected-access - if self._watcher is None: # pragma: no branch - if can_use_pidfd(): - self._watcher = asyncio.PidfdChildWatcher() - else: - self._watcher = asyncio.ThreadedChildWatcher() - if threading.current_thread() is threading.main_thread(): - self._watcher.attach_loop( - self._local._loop # type: ignore[attr-defined] # pylint: disable=protected-access - ) - - @property - def loop_name(self) -> str: - """Return name of the loop.""" - return self._loop_factory.__name__ # type: ignore[no-any-return,attr-defined] - - def new_event_loop(self) -> asyncio.AbstractEventLoop: - """Get the event loop.""" - loop: asyncio.AbstractEventLoop = super().new_event_loop() - loop.set_exception_handler(_async_loop_exception_handler) - - if self.debug: - loop.set_debug(True) - - executor = ThreadPoolExecutor( - thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS - ) - loop.set_default_executor(executor) - # bind the built-in time.monotonic directly as loop.time to avoid the - # overhead of the additional method call since its the most called loop - # method and its roughly 10%+ of all the call time in base_events.py - loop.time = monotonic # type: ignore[method-assign] - return loop - - -def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: - """Handle all exception inside the core loop.""" - kwargs = {} - if exception := context.get("exception"): - kwargs["exc_info"] = (type(exception), exception, exception.__traceback__) - - logger = logging.getLogger(__package__) - if source_traceback := context.get("source_traceback"): - stack_summary = "".join(traceback.format_list(source_traceback)) - logger.error( - "Error doing job: %s: %s", - context["message"], - stack_summary, - **kwargs, # type: ignore[arg-type] - ) - return - - logger.error( - "Error doing job: %s", - context["message"], - **kwargs, # type: ignore[arg-type] - ) - - -def start_dashboard(args) -> None: - """Start the dashboard.""" - settings.parse_args(args) - - if settings.using_auth: - path = esphome_storage_path() - storage = EsphomeStorageJSON.load(path) - if storage is None: - storage = EsphomeStorageJSON.get_default() - storage.save(path) - settings.cookie_secret = storage.cookie_secret - - asyncio.set_event_loop_policy(DashboardEventLoopPolicy(settings.verbose)) - - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(async_start(args)) - - -async def async_start(args) -> None: - """Start the dashboard.""" - dashboard = DASHBOARD - await dashboard.async_setup() - sock: socket.socket | None = args.socket - address: str | None = args.address - port: int | None = args.port - - start_web_server(make_app(args.verbose), sock, address, port, settings.config_dir) - - if args.open_ui: - import webbrowser - - webbrowser.open(f"http://{args.address}:{args.port}") - - try: - await dashboard.async_run() - finally: - if sock: - Path(sock).unlink() diff --git a/esphome/dashboard/dns.py b/esphome/dashboard/dns.py deleted file mode 100644 index eb4a87dbfb..0000000000 --- a/esphome/dashboard/dns.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import suppress -from ipaddress import ip_address -import logging - -from icmplib import NameLookupError, async_resolve - -RESOLVE_TIMEOUT = 3.0 - -_LOGGER = logging.getLogger(__name__) - -_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError) - - -async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: - """Wrap the icmplib async_resolve function.""" - with suppress(ValueError): - return [str(ip_address(hostname))] - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - return await async_resolve(hostname) - except _RESOLVE_EXCEPTIONS as ex: - # If the hostname ends with .local and resolution failed, - # try the bare hostname as a fallback since mDNS may not be - # working on the system but unicast DNS might resolve it - if hostname.endswith(".local"): - bare_hostname = hostname[:-6] # Remove ".local" - try: - async with asyncio.timeout(RESOLVE_TIMEOUT): - result = await async_resolve(bare_hostname) - _LOGGER.debug( - "Bare hostname %s resolved to %s", bare_hostname, result - ) - return result - except _RESOLVE_EXCEPTIONS: - _LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname) - return ex - - -class DNSCache: - """DNS cache for the dashboard.""" - - def __init__(self, ttl: int | None = 120) -> None: - """Initialize the DNSCache.""" - self._cache: dict[str, tuple[float, list[str] | Exception]] = {} - self._ttl = ttl - - def get_cached_addresses( - self, hostname: str, now_monotonic: float - ) -> list[str] | None: - """Get cached addresses without triggering resolution. - - Returns None if not in cache, list of addresses if found. - """ - # Normalize hostname for consistent lookups - normalized = hostname.rstrip(".").lower() - if expire_time_addresses := self._cache.get(normalized): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic and not isinstance(addresses, Exception): - return addresses - return None - - async def async_resolve( - self, hostname: str, now_monotonic: float - ) -> list[str] | Exception: - """Resolve a hostname to a list of IP address.""" - if expire_time_addresses := self._cache.get(hostname): - expire_time, addresses = expire_time_addresses - if expire_time > now_monotonic: - return addresses - - expires = now_monotonic + self._ttl - addresses = await _async_resolve_wrapper(hostname) - self._cache[hostname] = (expires, addresses) - return addresses diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py deleted file mode 100644 index 95b8a7b2ae..0000000000 --- a/esphome/dashboard/entries.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections import defaultdict -from dataclasses import dataclass -from functools import lru_cache -import logging -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from esphome import const, util -from esphome.enum import StrEnum -from esphome.storage_json import StorageJSON, ext_storage_path - -from .const import DASHBOARD_COMMAND, DashboardEvent -from .util.subprocess import async_run_system_command - -if TYPE_CHECKING: - from .core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -DashboardCacheKeyType = tuple[int, int, float, int] - - -@dataclass(frozen=True) -class EntryState: - """Represents the state of an entry.""" - - reachable: ReachableState - source: EntryStateSource - - -class EntryStateSource(StrEnum): - MDNS = "mdns" - PING = "ping" - MQTT = "mqtt" - UNKNOWN = "unknown" - - -class ReachableState(StrEnum): - ONLINE = "online" - OFFLINE = "offline" - DNS_FAILURE = "dns_failure" - UNKNOWN = "unknown" - - -_BOOL_TO_REACHABLE_STATE = { - True: ReachableState.ONLINE, - False: ReachableState.OFFLINE, - None: ReachableState.UNKNOWN, -} -_REACHABLE_STATE_TO_BOOL = { - ReachableState.ONLINE: True, - ReachableState.OFFLINE: False, - ReachableState.DNS_FAILURE: False, - ReachableState.UNKNOWN: None, -} - -UNKNOWN_STATE = EntryState(ReachableState.UNKNOWN, EntryStateSource.UNKNOWN) - - -@lru_cache # creating frozen dataclass instances is expensive, so we cache them -def bool_to_entry_state(value: bool | None, source: EntryStateSource) -> EntryState: - """Convert a bool to an entry state.""" - return EntryState(_BOOL_TO_REACHABLE_STATE[value], source) - - -def entry_state_to_bool(value: EntryState) -> bool | None: - """Convert an entry state to a bool.""" - return _REACHABLE_STATE_TO_BOOL[value.reachable] - - -class DashboardEntries: - """Represents all dashboard entries.""" - - __slots__ = ( - "_dashboard", - "_loop", - "_config_dir", - "_entries", - "_entry_states", - "_loaded_entries", - "_update_lock", - "_name_to_entry", - ) - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the DashboardEntries.""" - self._dashboard = dashboard - self._loop = asyncio.get_running_loop() - self._config_dir = dashboard.settings.config_dir - # Entries are stored as - # { - # "path/to/file.yaml": DashboardEntry, - # ... - # } - self._entries: dict[Path, DashboardEntry] = {} - self._loaded_entries = False - self._update_lock = asyncio.Lock() - self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set) - - def get(self, path: Path) -> DashboardEntry | None: - """Get an entry by path.""" - return self._entries.get(path) - - def get_by_name(self, name: str) -> set[DashboardEntry] | None: - """Get an entry by name.""" - return self._name_to_entry.get(name) - - async def _async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def all(self) -> list[DashboardEntry]: - """Return all entries.""" - return asyncio.run_coroutine_threadsafe(self._async_all(), self._loop).result() - - def async_all(self) -> list[DashboardEntry]: - """Return all entries.""" - return list(self._entries.values()) - - def set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state(entry, state), self._loop - ).result() - - async def _async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - self.async_set_state(entry, state) - - def set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_online_or_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - self.async_set_state_if_online_or_source(entry, state) - - def async_set_state_if_online_or_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if its online or provided by the source or unknown.""" - if ( - state.reachable is ReachableState.ONLINE - and entry.state.reachable is not ReachableState.ONLINE - ) or entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def set_state_if_source(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry if provided by the source or unknown.""" - asyncio.run_coroutine_threadsafe( - self._async_set_state_if_source(entry, state), self._loop - ).result() - - async def _async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if rovided by the source or unknown.""" - self.async_set_state_if_source(entry, state) - - def async_set_state_if_source( - self, entry: DashboardEntry, state: EntryState - ) -> None: - """Set the state for an entry if provided by the source or unknown.""" - if entry.state.source in ( - EntryStateSource.UNKNOWN, - state.source, - ): - self.async_set_state(entry, state) - - def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: - """Set the state for an entry.""" - if entry.state == state: - return - entry.state = state - self._dashboard.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - async def async_request_update_entries(self) -> None: - """Request an update of the dashboard entries from disk. - - If an update is already in progress, this will do nothing. - """ - if self._update_lock.locked(): - _LOGGER.debug("Dashboard entries are already being updated") - return - await self.async_update_entries() - - async def async_update_entries(self) -> None: - """Update the dashboard entries from disk.""" - async with self._update_lock: - await self._async_update_entries() - - def _load_entries( - self, entries: dict[DashboardEntry, DashboardCacheKeyType] - ) -> None: - """Load all entries from disk.""" - for entry, cache_key in entries.items(): - _LOGGER.debug( - "Loading dashboard entry %s because cache key changed: %s", - entry.path, - cache_key, - ) - entry.load_from_disk(cache_key) - - async def _async_update_entries(self) -> list[DashboardEntry]: - """Sync the dashboard entries from disk.""" - _LOGGER.debug("Updating dashboard entries") - # At some point it would be nice to use watchdog to avoid polling - - path_to_cache_key = await self._loop.run_in_executor( - None, self._get_path_to_cache_key - ) - entries = self._entries - name_to_entry = self._name_to_entry - added: dict[DashboardEntry, DashboardCacheKeyType] = {} - updated: dict[DashboardEntry, DashboardCacheKeyType] = {} - removed: set[DashboardEntry] = { - entry - for filename, entry in entries.items() - if filename not in path_to_cache_key - } - original_names: dict[DashboardEntry, str] = {} - - for path, cache_key in path_to_cache_key.items(): - if not (entry := entries.get(path)): - entry = DashboardEntry(path, cache_key) - added[entry] = cache_key - continue - - if entry.cache_key != cache_key: - updated[entry] = cache_key - original_names[entry] = entry.name - - if added or updated: - await self._loop.run_in_executor( - None, self._load_entries, {**added, **updated} - ) - - bus = self._dashboard.bus - for entry in added: - entries[entry.path] = entry - name_to_entry[entry.name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": entry}) - - for entry in removed: - del entries[entry.path] - name_to_entry[entry.name].discard(entry) - bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": entry}) - - for entry in updated: - if (original_name := original_names[entry]) != (current_name := entry.name): - name_to_entry[original_name].discard(entry) - name_to_entry[current_name].add(entry) - bus.async_fire(DashboardEvent.ENTRY_UPDATED, {"entry": entry}) - - def _get_path_to_cache_key(self) -> dict[Path, DashboardCacheKeyType]: - """Return a dict of path to cache key.""" - path_to_cache_key: dict[Path, DashboardCacheKeyType] = {} - # - # The cache key is (inode, device, mtime, size) - # which allows us to avoid locking since it ensures - # every iteration of this call will always return the newest - # items from disk at the cost of a stat() call on each - # file which is much faster than reading the file - # for the cache hit case which is the common case. - # - for file in util.list_yaml_files([self._config_dir]): - try: - # Prefer the json storage path if it exists - stat = ext_storage_path(file.name).stat() - except OSError: - try: - # Fallback to the yaml file if the storage - # file does not exist or could not be generated - stat = file.stat() - except OSError: - # File was deleted, ignore - continue - path_to_cache_key[file] = ( - stat.st_ino, - stat.st_dev, - stat.st_mtime, - stat.st_size, - ) - return path_to_cache_key - - def async_schedule_storage_json_update(self, filename: str) -> None: - """Schedule a task to update the storage JSON file.""" - self._dashboard.async_create_background_task( - async_run_system_command( - [*DASHBOARD_COMMAND, "compile", "--only-generate", filename] - ) - ) - - -class DashboardEntry: - """Represents a single dashboard entry. - - This class is thread-safe and read-only. - """ - - __slots__ = ( - "path", - "filename", - "_storage_path", - "cache_key", - "storage", - "state", - "_to_dict", - ) - - def __init__(self, path: Path, cache_key: DashboardCacheKeyType) -> None: - """Initialize the DashboardEntry.""" - self.path = path - self.filename: str = path.name - self._storage_path = ext_storage_path(self.filename) - self.cache_key = cache_key - self.storage: StorageJSON | None = None - self.state = UNKNOWN_STATE - self._to_dict: dict[str, Any] | None = None - - def __repr__(self) -> str: - """Return the representation of this entry.""" - return ( - f"DashboardEntry(path={self.path} " - f"address={self.address} " - f"web_port={self.web_port} " - f"name={self.name} " - f"no_mdns={self.no_mdns} " - f"state={self.state} " - ")" - ) - - def to_dict(self) -> dict[str, Any]: - """Return a dict representation of this entry. - - The dict includes the loaded configuration but not - the current state of the entry. - """ - if self._to_dict is None: - self._to_dict = { - "name": self.name, - "friendly_name": self.friendly_name, - "configuration": self.filename, - "loaded_integrations": sorted(self.loaded_integrations), - "deployed_version": self.update_old, - "current_version": self.update_new, - "path": str(self.path), - "comment": self.comment, - "address": self.address, - "web_port": self.web_port, - "target_platform": self.target_platform, - } - return self._to_dict - - def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None: - """Load this entry from disk.""" - self.storage = StorageJSON.load(self._storage_path) - self._to_dict = None - # - # Currently StorageJSON.load() will return None if the file does not exist - # - # StorageJSON currently does not provide an updated cache key so we use the - # one that is passed in. - # - # The cache key was read from the disk moments ago and may be stale but - # it does not matter since we are polling anyways, and the next call to - # async_update_entries() will load it again in the extremely rare case that - # it changed between the two calls. - # - if cache_key: - self.cache_key = cache_key - - @property - def address(self) -> str | None: - """Return the address of this entry.""" - if self.storage is None: - return None - return self.storage.address - - @property - def no_mdns(self) -> bool | None: - """Return the no_mdns of this entry.""" - if self.storage is None: - return None - return self.storage.no_mdns - - @property - def web_port(self) -> int | None: - """Return the web port of this entry.""" - if self.storage is None: - return None - return self.storage.web_port - - @property - def name(self) -> str: - """Return the name of this entry.""" - if self.storage is None: - return self.filename.replace(".yml", "").replace(".yaml", "") - return self.storage.name - - @property - def friendly_name(self) -> str: - """Return the friendly name of this entry.""" - if self.storage is None: - return self.name - return self.storage.friendly_name - - @property - def comment(self) -> str | None: - """Return the comment of this entry.""" - if self.storage is None: - return None - return self.storage.comment - - @property - def target_platform(self) -> str | None: - """Return the target platform of this entry.""" - if self.storage is None: - return None - return self.storage.target_platform - - @property - def update_available(self) -> bool: - """Return if an update is available for this entry.""" - if self.storage is None: - return True - return self.update_old != self.update_new - - @property - def update_old(self) -> str: - if self.storage is None: - return "" - return self.storage.esphome_version or "" - - @property - def update_new(self) -> str: - return const.__version__ - - @property - def loaded_integrations(self) -> set[str]: - if self.storage is None: - return [] - return self.storage.loaded_integrations diff --git a/esphome/dashboard/models.py b/esphome/dashboard/models.py deleted file mode 100644 index 47ddddd5ce..0000000000 --- a/esphome/dashboard/models.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Data models and builders for the dashboard.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, TypedDict - -if TYPE_CHECKING: - from esphome.zeroconf import DiscoveredImport - - from .core import ESPHomeDashboard - from .entries import DashboardEntry - - -class ImportableDeviceDict(TypedDict): - """Dictionary representation of an importable device.""" - - name: str - friendly_name: str | None - package_import_url: str - project_name: str - project_version: str - network: str - ignored: bool - - -class ConfiguredDeviceDict(TypedDict, total=False): - """Dictionary representation of a configured device.""" - - name: str - friendly_name: str | None - configuration: str - loaded_integrations: list[str] | None - deployed_version: str | None - current_version: str | None - path: str - comment: str | None - address: str | None - web_port: int | None - target_platform: str | None - - -class DeviceListResponse(TypedDict): - """Response for device list API.""" - - configured: list[ConfiguredDeviceDict] - importable: list[ImportableDeviceDict] - - -def build_importable_device_dict( - dashboard: ESPHomeDashboard, discovered: DiscoveredImport -) -> ImportableDeviceDict: - """Build the importable device dictionary.""" - return ImportableDeviceDict( - name=discovered.device_name, - friendly_name=discovered.friendly_name, - package_import_url=discovered.package_import_url, - project_name=discovered.project_name, - project_version=discovered.project_version, - network=discovered.network, - ignored=discovered.device_name in dashboard.ignored_devices, - ) - - -def build_device_list_response( - dashboard: ESPHomeDashboard, entries: list[DashboardEntry] -) -> DeviceListResponse: - """Build the device list response data.""" - configured = {entry.name for entry in entries} - return DeviceListResponse( - configured=[entry.to_dict() for entry in entries], - importable=[ - build_importable_device_dict(dashboard, res) - for res in dashboard.import_result.values() - if res.device_name not in configured - ], - ) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py deleted file mode 100644 index 3b22180b1d..0000000000 --- a/esphome/dashboard/settings.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import hmac -import os -from pathlib import Path -from typing import Any - -from esphome.core import CORE -from esphome.helpers import get_bool_env - -from .util.password import password_hash - -# Sentinel file name used for CORE.config_path when dashboard initializes. -# This ensures .parent returns the config directory instead of root. -_DASHBOARD_SENTINEL_FILE = "___DASHBOARD_SENTINEL___.yaml" - - -class DashboardSettings: - """Settings for the dashboard.""" - - __slots__ = ( - "config_dir", - "password_hash", - "username", - "using_password", - "on_ha_addon", - "cookie_secret", - "absolute_config_dir", - "verbose", - ) - - def __init__(self) -> None: - """Initialize the dashboard settings.""" - self.config_dir: Path = None - self.password_hash: bytes = b"" - self.username: str = "" - self.using_password: bool = False - self.on_ha_addon: bool = False - self.cookie_secret: str | None = None - self.absolute_config_dir: Path | None = None - self.verbose: bool = False - - def parse_args(self, args: Any) -> None: - """Parse the arguments.""" - self.on_ha_addon: bool = args.ha_addon - password = args.password or os.getenv("PASSWORD") or "" - if not self.on_ha_addon: - self.username = args.username or os.getenv("USERNAME") or "" - self.using_password = bool(password) - if self.using_password: - self.password_hash = password_hash(password) - self.config_dir = Path(args.configuration) - self.absolute_config_dir = self.config_dir.resolve() - self.verbose = args.verbose - # Set to a sentinel file so .parent gives us the config directory. - # Previously this was `os.path.join(self.config_dir, ".")` which worked because - # os.path.dirname("/config/.") returns "/config", but Path("/config/.").parent - # normalizes to Path("/config") first, then .parent returns Path("/"), breaking - # secret resolution. Using a sentinel file ensures .parent gives the correct directory. - CORE.config_path = self.config_dir / _DASHBOARD_SENTINEL_FILE - - @property - def relative_url(self) -> str: - return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL") or "/" - - @property - def status_use_mqtt(self) -> bool: - return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") - - @property - def using_ha_addon_auth(self) -> bool: - if not self.on_ha_addon: - return False - return not get_bool_env("DISABLE_HA_AUTHENTICATION") - - @property - def using_auth(self) -> bool: - return self.using_password or self.using_ha_addon_auth - - @property - def streamer_mode(self) -> bool: - return get_bool_env("ESPHOME_STREAMER_MODE") - - def check_password(self, username: str, password: str) -> bool: - if not self.using_auth: - return True - # Compare in constant running time (to prevent timing attacks) - username_matches = hmac.compare_digest( - username.encode("utf-8"), self.username.encode("utf-8") - ) - password_matches = hmac.compare_digest( - self.password_hash, password_hash(password) - ) - return username_matches and password_matches - - def rel_path(self, *args: Any) -> Path: - """Return a path relative to the ESPHome config folder.""" - joined_path = self.config_dir / Path(*args) - # Raises ValueError if not relative to ESPHome config folder - joined_path.resolve().relative_to(self.absolute_config_dir) - return joined_path diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py deleted file mode 100644 index 9da9bb8f01..0000000000 --- a/esphome/dashboard/status/mdns.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import typing - -from zeroconf import AddressResolver, IPVersion - -from esphome.address_cache import normalize_hostname -from esphome.zeroconf import ( - ESPHOME_SERVICE_TYPE, - AsyncEsphomeZeroconf, - DashboardBrowser, - DashboardImportDiscovery, - DashboardStatus, - DiscoveredImport, -) - -from ..const import SENTINEL, DashboardEvent -from ..entries import DashboardEntry, EntryStateSource, bool_to_entry_state -from ..models import build_importable_device_dict - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - -_LOGGER = logging.getLogger(__name__) - - -class MDNSStatus: - """Class that updates the mdns status.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the MDNSStatus class.""" - super().__init__() - self.aiozc: AsyncEsphomeZeroconf | None = None - # This is the current mdns state for each host (True, False, None) - self.host_mdns_state: dict[str, bool | None] = {} - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - def async_setup(self) -> bool: - """Set up the MDNSStatus class.""" - try: - self.aiozc = AsyncEsphomeZeroconf() - except OSError as e: - _LOGGER.warning( - "Failed to initialize zeroconf, will fallback to ping: %s", e - ) - return False - return True - - async def async_resolve_host(self, host_name: str) -> list[str] | None: - """Resolve a host name to an address in a thread-safe manner.""" - if aiozc := self.aiozc: - return await aiozc.async_resolve_host(host_name) - return None - - def get_cached_addresses(self, host_name: str) -> list[str] | None: - """Get cached addresses for a host without triggering resolution. - - Returns None if not in cache or no zeroconf available. - """ - if not self.aiozc: - _LOGGER.debug("No zeroconf instance available for %s", host_name) - return None - - # Normalize hostname and get the base name - normalized = normalize_hostname(host_name) - base_name = normalized.partition(".")[0] - - # Try to load from zeroconf cache without triggering resolution - resolver_name = f"{base_name}.local." - info = AddressResolver(resolver_name) - # Let zeroconf use its own current time for cache checking - if info.load_from_cache(self.aiozc.zeroconf): - addresses = info.parsed_scoped_addresses(IPVersion.All) - _LOGGER.debug("Found %s in zeroconf cache: %s", resolver_name, addresses) - return addresses - _LOGGER.debug("Not found in zeroconf cache: %s", resolver_name) - return None - - def _on_import_update(self, name: str, discovered: DiscoveredImport | None) -> None: - """Handle importable device updates.""" - if discovered is None: - # Device removed - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": name} - ) - else: - # Device added - self.dashboard.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - {"device": build_importable_device_dict(self.dashboard, discovered)}, - ) - - async def async_refresh_hosts(self) -> None: - """Refresh the hosts to track.""" - dashboard = self.dashboard - host_mdns_state = self.host_mdns_state - entries = dashboard.entries - poll_names: dict[str, set[DashboardEntry]] = {} - for entry in entries.async_all(): - if entry.no_mdns: - continue - # If we just adopted/imported this host, we likely - # already have a state for it, so we should make sure - # to set it so the dashboard shows it as online - if entry.loaded_integrations and "api" not in entry.loaded_integrations: - # No api available so we have to poll since - # the device won't respond to a request to ._esphomelib._tcp.local. - poll_names.setdefault(entry.name, set()).add(entry) - elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL: - self._async_set_state(entry, online) - if poll_names and self.aiozc: - results = await asyncio.gather( - *(self.aiozc.async_resolve_host(name) for name in poll_names) - ) - for name, address_list in zip(poll_names, results, strict=True): - result = bool(address_list) - host_mdns_state[name] = result - for entry in poll_names[name]: - self._async_set_state(entry, result) - - def _async_set_state(self, entry: DashboardEntry, result: bool | None) -> None: - """Set the state of an entry.""" - state = bool_to_entry_state(result, EntryStateSource.MDNS) - if result: - # If we can reach it via mDNS, we always set it online - # since its the fastest source if its working - self.dashboard.entries.async_set_state(entry, state) - else: - # However if we can't reach it via mDNS - # we only set it to offline if the state is unknown - # or from mDNS - self.dashboard.entries.async_set_state_if_source(entry, state) - - async def async_run(self) -> None: - """Run the mdns status.""" - dashboard = self.dashboard - entries = dashboard.entries - host_mdns_state = self.host_mdns_state - - def on_update(dat: dict[str, bool | None]) -> None: - """Update the entry state.""" - for name, result in dat.items(): - host_mdns_state[name] = result - if matching_entries := entries.get_by_name(name): - for entry in matching_entries: - self._async_set_state(entry, result) - - stat = DashboardStatus(on_update) - - imports = DashboardImportDiscovery(self._on_import_update) - dashboard.import_result = imports.import_state - - browser = DashboardBrowser( - self.aiozc.zeroconf, - ESPHOME_SERVICE_TYPE, - [stat.browser_callback, imports.browser_callback], - ) - - ping_request = dashboard.ping_request - while not dashboard.stop_event.is_set(): - await self.async_refresh_hosts() - await ping_request.wait() - ping_request.clear() - - await browser.async_cancel() - await self.aiozc.async_close() - self.aiozc = None diff --git a/esphome/dashboard/status/mqtt.py b/esphome/dashboard/status/mqtt.py deleted file mode 100644 index c3e4883849..0000000000 --- a/esphome/dashboard/status/mqtt.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import binascii -import json -import os -import threading -import typing - -from esphome import mqtt - -from ..entries import EntryStateSource, bool_to_entry_state - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -class MqttStatusThread(threading.Thread): - """Status thread to get the status of the devices via MQTT.""" - - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the status thread.""" - super().__init__() - self.dashboard = dashboard - - def run(self) -> None: - """Run the status thread.""" - dashboard = self.dashboard - entries = dashboard.entries - current_entries = entries.all() - - config = mqtt.config_from_env() - topic = "esphome/discover/#" - - def on_message(client, userdata, msg): - payload = msg.payload.decode(errors="backslashreplace") - if len(payload) > 0: - data = json.loads(payload) - if "name" not in data: - return - if matching_entries := entries.get_by_name(data["name"]): - for entry in matching_entries: - # Only override state if we don't have a state from another source - # or we have a state from MQTT and the device is reachable - entries.set_state_if_online_or_source( - entry, bool_to_entry_state(True, EntryStateSource.MQTT) - ) - - def on_connect(client, userdata, flags, return_code): - client.publish("esphome/discover", None, retain=False) - - mqttid = str(binascii.hexlify(os.urandom(6)).decode()) - - client = mqtt.prepare( - config, - [topic], - on_message, - on_connect, - None, - None, - f"esphome-dashboard-{mqttid}", - ) - client.loop_start() - - while not dashboard.stop_event.wait(2): - current_entries = entries.all() - # will be set to true on on_message - for entry in current_entries: - # Only override state if we don't have a state from another source - entries.set_state_if_source( - entry, bool_to_entry_state(False, EntryStateSource.MQTT) - ) - - client.publish("esphome/discover", None, retain=False) - dashboard.mqtt_ping_request.wait() - dashboard.mqtt_ping_request.clear() - - client.disconnect() - client.loop_stop() diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py deleted file mode 100644 index eb69fbb9b3..0000000000 --- a/esphome/dashboard/status/ping.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -import typing -from typing import cast - -from icmplib import Host, SocketPermissionError, async_ping - -from ..const import MAX_EXECUTOR_WORKERS -from ..entries import ( - DashboardEntry, - EntryState, - EntryStateSource, - ReachableState, - bool_to_entry_state, -) -from ..util.itertools import chunked - -if typing.TYPE_CHECKING: - from ..core import ESPHomeDashboard - - -_LOGGER = logging.getLogger(__name__) - -GROUP_SIZE = int(MAX_EXECUTOR_WORKERS / 2) - -DNS_FAILURE_STATE = EntryState(ReachableState.DNS_FAILURE, EntryStateSource.PING) - -MIN_PING_INTERVAL = 5 # ensure we don't ping too often - - -class PingStatus: - def __init__(self, dashboard: ESPHomeDashboard) -> None: - """Initialize the PingStatus class.""" - super().__init__() - self._loop = asyncio.get_running_loop() - self.dashboard = dashboard - - async def async_run(self) -> None: - """Run the ping status.""" - dashboard = self.dashboard - entries = dashboard.entries - privileged = await _can_use_icmp_lib_with_privilege() - if privileged is None: - _LOGGER.warning("Cannot use icmplib because privileges are insufficient") - return - - while not dashboard.stop_event.is_set(): - # Only ping if the dashboard is open - await dashboard.ping_request.wait() - dashboard.ping_request.clear() - iteration_start = time.monotonic() - current_entries = dashboard.entries.async_all() - to_ping: list[DashboardEntry] = [] - - for entry in current_entries: - if entry.address is None: - # No address or we already have a state from another source - # so no need to ping - continue - if ( - entry.state.reachable is ReachableState.ONLINE - and entry.state.source - not in (EntryStateSource.PING, EntryStateSource.UNKNOWN) - ): - # If we already have a state from another source and - # it's online, we don't need to ping - continue - to_ping.append(entry) - - # Resolve DNS for all entries - entries_with_addresses: dict[DashboardEntry, list[str]] = {} - for ping_group in chunked(to_ping, GROUP_SIZE): - ping_group = cast(list[DashboardEntry], ping_group) - now_monotonic = time.monotonic() - dns_results = await asyncio.gather( - *( - dashboard.dns_cache.async_resolve(entry.address, now_monotonic) - for entry in ping_group - ), - return_exceptions=True, - ) - - for entry, result in zip(ping_group, dns_results, strict=True): - if isinstance(result, Exception): - # Only update state if its unknown or from ping - # so we don't mark it as offline if we have a state - # from mDNS or MQTT - entries.async_set_state_if_source(entry, DNS_FAILURE_STATE) - continue - if isinstance(result, BaseException): - raise result - entries_with_addresses[entry] = result - - # Ping all entries with valid addresses - for ping_group in chunked(entries_with_addresses.items(), GROUP_SIZE): - entry_addresses = cast(tuple[DashboardEntry, list[str]], ping_group) - - results = await asyncio.gather( - *( - async_ping(addresses[0], privileged=privileged) - for _, addresses in entry_addresses - ), - return_exceptions=True, - ) - - for entry_address, result in zip(entry_addresses, results, strict=True): - if isinstance(result, Exception): - ping_result = False - elif isinstance(result, BaseException): - raise result - else: - host: Host = result - ping_result = host.is_alive - entry: DashboardEntry = entry_address[0] - # If we can reach it via ping, we always set it - # online, however if we can't reach it via ping - # we only set it to offline if the state is unknown - # or from ping - entries.async_set_state_if_online_or_source( - entry, - bool_to_entry_state(ping_result, EntryStateSource.PING), - ) - - if not dashboard.stop_event.is_set(): - iteration_duration = time.monotonic() - iteration_start - if iteration_duration < MIN_PING_INTERVAL: - await asyncio.sleep(MIN_PING_INTERVAL - iteration_duration) - - -async def _can_use_icmp_lib_with_privilege() -> None | bool: - """Verify we can create a raw socket.""" - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=True) - except SocketPermissionError: - try: - await async_ping("127.0.0.1", count=0, timeout=0, privileged=False) - except SocketPermissionError: - _LOGGER.debug( - "Cannot use icmplib because privileges are insufficient to create the" - " socket" - ) - return None - - _LOGGER.debug("Using icmplib in privileged=False mode") - return False - - _LOGGER.debug("Using icmplib in privileged=True mode") - return True diff --git a/esphome/dashboard/util/itertools.py b/esphome/dashboard/util/itertools.py deleted file mode 100644 index 54e95ef802..0000000000 --- a/esphome/dashboard/util/itertools.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from functools import partial -from itertools import islice -from typing import Any - - -def take(take_num: int, iterable: Iterable) -> list[Any]: - """Return first n items of the iterable as a list. - - From itertools recipes - """ - return list(islice(iterable, take_num)) - - -def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: - """Break *iterable* into lists of length *n*. - - From more-itertools - """ - return iter(partial(take, chunked_num, iter(iterable)), []) diff --git a/esphome/dashboard/util/password.py b/esphome/dashboard/util/password.py deleted file mode 100644 index e7ea28c25d..0000000000 --- a/esphome/dashboard/util/password.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -import hashlib - - -def password_hash(password: str) -> bytes: - """Create a hash of a password to transform it to a fixed-length digest. - - Note this is not meant for secure storage, but for securely comparing passwords. - """ - return hashlib.sha256(password.encode()).digest() diff --git a/esphome/dashboard/util/subprocess.py b/esphome/dashboard/util/subprocess.py deleted file mode 100644 index 583dd116e3..0000000000 --- a/esphome/dashboard/util/subprocess.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Iterable - - -async def async_system_command_status(command: Iterable[str]) -> bool: - """Run a system command checking only the status.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL, - close_fds=False, - ) - await process.wait() - return process.returncode == 0 - - -async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]: - """Run a system command and return a tuple of returncode, stdout, stderr.""" - process = await asyncio.create_subprocess_exec( - *command, - stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - close_fds=False, - ) - stdout, stderr = await process.communicate() - await process.wait() - return process.returncode, stdout, stderr diff --git a/esphome/dashboard/util/text.py b/esphome/dashboard/util/text.py deleted file mode 100644 index bdf9abfdb9..0000000000 --- a/esphome/dashboard/util/text.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Back-compat shim for ``friendly_name_slugify``. - -The function moved to :mod:`esphome.helpers` so it survives the legacy -dashboard's eventual removal — see the -``esphome.helpers.friendly_name_slugify`` docstring. This module -re-exports the name so existing -``from esphome.dashboard.util.text import friendly_name_slugify`` -imports keep working while downstream consumers migrate. -""" - -from __future__ import annotations - -from esphome.helpers import friendly_name_slugify - -__all__ = ["friendly_name_slugify"] diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py deleted file mode 100644 index f5203efe9c..0000000000 --- a/esphome/dashboard/web_server.py +++ /dev/null @@ -1,1645 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -import binascii -from collections.abc import Callable, Iterable -import contextlib -import datetime -import functools -from functools import partial -import gzip -import hashlib -import importlib -import json -import logging -import os -from pathlib import Path -import secrets -import shutil -import subprocess -import threading -import time -from typing import TYPE_CHECKING, Any, TypeVar -from urllib.parse import urlparse - -import tornado -import tornado.concurrent -import tornado.gen -import tornado.httpserver -import tornado.httputil -import tornado.ioloop -import tornado.iostream -from tornado.log import access_log -import tornado.netutil -import tornado.process -import tornado.queues -import tornado.web -import tornado.websocket -import voluptuous as vol -import yaml -from yaml.nodes import Node - -from esphome import const, yaml_util -from esphome.helpers import get_bool_env, mkdir_p, sort_ip_addresses -from esphome.platformio import toolchain -from esphome.storage_json import ( - StorageJSON, - archive_storage_path, - ext_storage_path, - trash_storage_path, -) -from esphome.util import get_serial_ports, shlex_quote -from esphome.yaml_util import FastestAvailableSafeLoader - -from ..helpers import write_file -from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent -from .core import DASHBOARD, ESPHomeDashboard, Event -from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool -from .models import build_device_list_response -from .util.subprocess import async_run_system_command -from .util.text import friendly_name_slugify - -if TYPE_CHECKING: - from requests import Response - -_LOGGER = logging.getLogger(__name__) - -ENV_DEV = "ESPHOME_DASHBOARD_DEV" - -COOKIE_AUTHENTICATED_YES = b"yes" - -AUTH_COOKIE_NAME = "authenticated" - - -settings = DASHBOARD.settings - - -def template_args() -> dict[str, Any]: - version = const.__version__ - if "b" in version: - docs_link = "https://beta.esphome.io/" - elif "dev" in version: - docs_link = "https://next.esphome.io/" - else: - docs_link = "https://www.esphome.io/" - - return { - "version": version, - "docs_link": docs_link, - "get_static_file_url": get_static_file_url, - "relative_url": settings.relative_url, - "streamer_mode": settings.streamer_mode, - "config_dir": settings.config_dir, - } - - -T = TypeVar("T", bound=Callable[..., Any]) - - -def authenticated(func: T) -> T: - @functools.wraps(func) - def decorator(self, *args: Any, **kwargs: Any): - if not is_authenticated(self): - self.redirect("./login") - return None - return func(self, *args, **kwargs) - - return decorator - - -def is_authenticated(handler: BaseHandler) -> bool: - """Check if the request is authenticated.""" - if settings.on_ha_addon: - # Handle ingress - disable auth on ingress port - # X-HA-Ingress is automatically stripped on the non-ingress server in nginx - header = handler.request.headers.get("X-HA-Ingress", "NO") - if str(header) == "YES": - return True - - if settings.using_auth: - if auth_header := handler.request.headers.get("Authorization"): - assert isinstance(auth_header, str) - if auth_header.startswith("Basic "): - try: - auth_decoded = base64.b64decode(auth_header[6:]).decode() - username, password = auth_decoded.split(":", 1) - except (binascii.Error, ValueError, UnicodeDecodeError): - return False - return settings.check_password(username, password) - return handler.get_secure_cookie(AUTH_COOKIE_NAME) == COOKIE_AUTHENTICATED_YES - - return True - - -def bind_config(func): - def decorator(self, *args, **kwargs): - configuration = self.get_argument("configuration") - kwargs = kwargs.copy() - kwargs["configuration"] = configuration - return func(self, *args, **kwargs) - - return decorator - - -# pylint: disable=abstract-method -class BaseHandler(tornado.web.RequestHandler): - pass - - -def websocket_class(cls): - # pylint: disable=protected-access - if not hasattr(cls, "_message_handlers"): - cls._message_handlers = {} - - for method in cls.__dict__.values(): - if hasattr(method, "_message_handler"): - cls._message_handlers[method._message_handler] = method - - return cls - - -def websocket_method(name): - def wrap(fn): - # pylint: disable=protected-access - fn._message_handler = name - return fn - - return wrap - - -class CheckOriginMixin: - """Mixin to handle WebSocket origin checks for reverse proxy setups.""" - - def check_origin(self, origin: str) -> bool: - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - - -@websocket_class -class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """Base class for ESPHome websocket commands.""" - - def __init__( - self, - application: tornado.web.Application, - request: tornado.httputil.HTTPServerRequest, - **kwargs: Any, - ) -> None: - """Initialize the websocket.""" - super().__init__(application, request, **kwargs) - self._proc = None - self._queue = None - self._is_closed = False - # Windows doesn't support non-blocking pipes, - # use Popen() with a reading thread instead - self._use_popen = os.name == "nt" - - def open(self, *args: str, **kwargs: str) -> None: - """Handle new WebSocket connection.""" - # Ensure messages from the subprocess are sent immediately - # to avoid a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - @authenticated - async def on_message( # pylint: disable=invalid-overridden-method - self, message: str - ) -> None: - # Since tornado 4.5, on_message is allowed to be a coroutine - # Messages are always JSON, 500 when not - json_message = json.loads(message) - type_ = json_message["type"] - # pylint: disable=no-member - handlers = type(self)._message_handlers - if type_ not in handlers: - _LOGGER.warning("Requested unknown message type %s", type_) - return - - await handlers[type_](self, json_message) - - @websocket_method("spawn") - async def handle_spawn(self, json_message: dict[str, Any]) -> None: - if self._proc is not None: - # spawn can only be called once - return - command = await self.build_command(json_message) - _LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command)) - - if self._use_popen: - self._queue = tornado.queues.Queue() - # pylint: disable=consider-using-with - self._proc = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - close_fds=False, - ) - stdout_thread = threading.Thread(target=self._stdout_thread) - stdout_thread.daemon = True - stdout_thread.start() - else: - self._proc = tornado.process.Subprocess( - command, - stdout=tornado.process.Subprocess.STREAM, - stderr=subprocess.STDOUT, - stdin=tornado.process.Subprocess.STREAM, - close_fds=False, - ) - self._proc.set_exit_callback(self._proc_on_exit) - - tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout) - - @property - def is_process_active(self) -> bool: - return self._proc is not None and self._proc.returncode is None - - @websocket_method("stdin") - async def handle_stdin(self, json_message: dict[str, Any]) -> None: - if not self.is_process_active: - return - text: str = json_message["data"] - data = text.encode("utf-8", "replace") - _LOGGER.debug("< stdin: %s", data) - self._proc.stdin.write(data) - - @tornado.gen.coroutine - def _redirect_stdout(self) -> None: - reg = b"[\n\r]" - - while True: - try: - if self._use_popen: - data: bytes = yield self._queue.get() - if data is None: - self._proc_on_exit(self._proc.poll()) - break - else: - data: bytes = yield self._proc.stdout.read_until_regex(reg) - except tornado.iostream.StreamClosedError: - break - - text = data.decode("utf-8", "replace") - _LOGGER.debug("> stdout: %s", text) - self.write_message({"event": "line", "data": text}) - - def _stdout_thread(self) -> None: - if not self._use_popen: - return - line = b"" - cr = False - while True: - data = self._proc.stdout.read(1) - if data: - if data == b"\r": - cr = True - elif data == b"\n": - self._queue.put_nowait(line + b"\n") - line = b"" - cr = False - elif cr: - self._queue.put_nowait(line + b"\r") - line = data - cr = False - else: - line += data - if self._proc.poll() is not None: - break - self._proc.wait(1.0) - self._queue.put_nowait(None) - - def _proc_on_exit(self, returncode: int) -> None: - if not self._is_closed: - # Check if the proc was not forcibly closed - _LOGGER.info("Process exited with return code %s", returncode) - self.write_message({"event": "exit", "code": returncode}) - self.close() - - def on_close(self) -> None: - # Check if proc exists (if 'start' has been run) - if self.is_process_active: - _LOGGER.debug("Terminating process") - if self._use_popen: - self._proc.terminate() - else: - self._proc.proc.terminate() - # Shutdown proc on WS close - self._is_closed = True - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - raise NotImplementedError - - -def build_cache_arguments( - entry: DashboardEntry | None, - dashboard: ESPHomeDashboard, - now: float, -) -> list[str]: - """Build cache arguments for passing to CLI. - - Args: - entry: Dashboard entry for the configuration - dashboard: Dashboard instance with cache access - now: Current monotonic time for DNS cache expiry checks - - Returns: - List of cache arguments to pass to CLI - """ - cache_args: list[str] = [] - - if not entry: - return cache_args - - _LOGGER.debug( - "Building cache for entry (address=%s, name=%s)", - entry.address, - entry.name, - ) - - def add_cache_entry(hostname: str, addresses: list[str], cache_type: str) -> None: - """Add a cache entry to the command arguments.""" - if not addresses: - return - normalized = hostname.rstrip(".").lower() - cache_args.extend( - [ - f"--{cache_type}-address-cache", - f"{normalized}={','.join(sort_ip_addresses(addresses))}", - ] - ) - - # Check entry.address for cached addresses - if use_address := entry.address: - if use_address.endswith(".local"): - # mDNS cache for .local addresses - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(use_address) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "mdns") - # DNS cache for non-.local addresses - elif cached := dashboard.dns_cache.get_cached_addresses(use_address, now): - _LOGGER.debug("DNS cache hit for %s: %s", use_address, cached) - add_cache_entry(use_address, cached, "dns") - - # Check entry.name if we haven't already cached via address - # For mDNS devices, entry.name typically doesn't have .local suffix - if entry.name and not use_address: - mdns_name = ( - f"{entry.name}.local" if not entry.name.endswith(".local") else entry.name - ) - if (mdns := dashboard.mdns_status) and ( - cached := mdns.get_cached_addresses(mdns_name) - ): - _LOGGER.debug("mDNS cache hit for %s: %s", mdns_name, cached) - add_cache_entry(mdns_name, cached, "mdns") - - return cache_args - - -class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): - """Base class for commands that require a port.""" - - async def build_device_command( - self, args: list[str], json_message: dict[str, Any] - ) -> list[str]: - """Build the command to run.""" - dashboard = DASHBOARD - entries = dashboard.entries - configuration = json_message["configuration"] - config_file = settings.rel_path(configuration) - port = json_message["port"] - - # Build cache arguments to pass to CLI - cache_args: list[str] = [] - - if ( - port == "OTA" # pylint: disable=too-many-boolean-expressions - and (entry := entries.get(config_file)) - and entry.loaded_integrations - and "api" in entry.loaded_integrations - ): - cache_args = build_cache_arguments(entry, dashboard, time.monotonic()) - - # Cache arguments must come before the subcommand - cmd = [*DASHBOARD_COMMAND, *cache_args, *args, config_file, "--device", port] - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeLogsHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - cmd = await self.build_device_command(["logs"], json_message) - if json_message.get("no_states"): - cmd.append("--no-states") - _LOGGER.debug("Built command: %s", cmd) - return cmd - - -class EsphomeRenameHandler(EsphomeCommandWebSocket): - old_name: str - - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - self.old_name = json_message["configuration"] - return [ - *DASHBOARD_COMMAND, - "rename", - config_file, - json_message["newName"], - ] - - def _proc_on_exit(self, returncode): - super()._proc_on_exit(returncode) - - if returncode != 0: - return - - # Remove the old ping result from the cache - entries = DASHBOARD.entries - if entry := entries.get(self.old_name): - entries.async_set_state(entry, UNKNOWN_STATE) - - -class EsphomeUploadHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["upload"], json_message) - - -class EsphomeRunHandler(EsphomePortCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - """Build the command to run.""" - return await self.build_device_command(["run"], json_message) - - -class EsphomeCompileHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "compile"] - if json_message.get("only_generate", False): - command.append("--only-generate") - command.append(config_file) - return command - - -class EsphomeValidateHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - command = [*DASHBOARD_COMMAND, "config", config_file] - if not settings.streamer_mode: - command.append("--show-secrets") - return command - - -class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] - - -class EsphomeCleanAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - clean_build_dir = json_message.get("clean_build_dir", True) - if clean_build_dir: - return [*DASHBOARD_COMMAND, "clean-all", settings.config_dir] - return [*DASHBOARD_COMMAND, "clean-all"] - - -class EsphomeCleanHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - config_file = settings.rel_path(json_message["configuration"]) - return [*DASHBOARD_COMMAND, "clean", config_file] - - -class EsphomeVscodeHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"] - - -class EsphomeAceEditorHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir] - - -class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): - async def build_command(self, json_message: dict[str, Any]) -> list[str]: - return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] - - -# Dashboard polling constants -DASHBOARD_POLL_INTERVAL = 2 # seconds -DASHBOARD_ENTRIES_UPDATE_INTERVAL = 10 # seconds -DASHBOARD_ENTRIES_UPDATE_ITERATIONS = ( - DASHBOARD_ENTRIES_UPDATE_INTERVAL // DASHBOARD_POLL_INTERVAL -) - - -class DashboardSubscriber: - """Manages dashboard event polling task lifecycle based on active subscribers.""" - - def __init__(self) -> None: - """Initialize the dashboard subscriber.""" - self._subscribers: set[DashboardEventsWebSocket] = set() - self._event_loop_task: asyncio.Task | None = None - self._refresh_event: asyncio.Event = asyncio.Event() - - def subscribe(self, subscriber: DashboardEventsWebSocket) -> Callable[[], None]: - """Subscribe to dashboard updates and start event loop if needed.""" - self._subscribers.add(subscriber) - if not self._event_loop_task or self._event_loop_task.done(): - self._event_loop_task = asyncio.create_task(self._event_loop()) - _LOGGER.info("Started dashboard event loop") - return partial(self._unsubscribe, subscriber) - - def _unsubscribe(self, subscriber: DashboardEventsWebSocket) -> None: - """Unsubscribe from dashboard updates and stop event loop if no subscribers.""" - self._subscribers.discard(subscriber) - if ( - not self._subscribers - and self._event_loop_task - and not self._event_loop_task.done() - ): - self._event_loop_task.cancel() - self._event_loop_task = None - _LOGGER.info("Stopped dashboard event loop - no subscribers") - - def request_refresh(self) -> None: - """Signal the polling loop to refresh immediately.""" - self._refresh_event.set() - - async def _event_loop(self) -> None: - """Run the event polling loop while there are subscribers.""" - dashboard = DASHBOARD - entries_update_counter = 0 - - while self._subscribers: - # Signal that we need ping updates (non-blocking) - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - - # Check if it's time to update entries or if refresh was requested - entries_update_counter += 1 - if ( - entries_update_counter >= DASHBOARD_ENTRIES_UPDATE_ITERATIONS - or self._refresh_event.is_set() - ): - entries_update_counter = 0 - await dashboard.entries.async_request_update_entries() - # Clear the refresh event if it was set - self._refresh_event.clear() - - # Wait for either timeout or refresh event - try: - async with asyncio.timeout(DASHBOARD_POLL_INTERVAL): - await self._refresh_event.wait() - # If we get here, refresh was requested - continue loop immediately - except TimeoutError: - # Normal timeout - continue with regular polling - pass - - -# Global dashboard subscriber instance -DASHBOARD_SUBSCRIBER = DashboardSubscriber() - - -@websocket_class -class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): - """WebSocket handler for real-time dashboard events.""" - - _event_listeners: list[Callable[[], None]] | None = None - _dashboard_unsubscribe: Callable[[], None] | None = None - - async def get(self, *args: str, **kwargs: str) -> None: - """Handle WebSocket upgrade request.""" - if not is_authenticated(self): - self.set_status(401) - self.finish("Unauthorized") - return - await super().get(*args, **kwargs) - - async def open(self, *args: str, **kwargs: str) -> None: # pylint: disable=invalid-overridden-method - """Handle new WebSocket connection.""" - # Ensure messages are sent immediately to avoid - # a 200-500ms delay when nodelay is not set. - self.set_nodelay(True) - - # Update entries first - await DASHBOARD.entries.async_request_update_entries() - # Send initial state - self._send_initial_state() - # Subscribe to events - self._subscribe_to_events() - # Subscribe to dashboard updates - self._dashboard_unsubscribe = DASHBOARD_SUBSCRIBER.subscribe(self) - _LOGGER.debug("Dashboard status WebSocket opened") - - def _send_initial_state(self) -> None: - """Send initial device list and ping status.""" - entries = DASHBOARD.entries.async_all() - - # Send initial state - self._safe_send_message( - { - "event": DashboardEvent.INITIAL_STATE, - "data": { - "devices": build_device_list_response(DASHBOARD, entries), - "ping": { - entry.filename: entry_state_to_bool(entry.state) - for entry in entries - }, - }, - } - ) - - def _subscribe_to_events(self) -> None: - """Subscribe to dashboard events.""" - async_add_listener = DASHBOARD.bus.async_add_listener - # Subscribe to all events - self._event_listeners = [ - async_add_listener( - DashboardEvent.ENTRY_STATE_CHANGED, self._on_entry_state_changed - ), - async_add_listener( - DashboardEvent.ENTRY_ADDED, - self._make_entry_handler(DashboardEvent.ENTRY_ADDED), - ), - async_add_listener( - DashboardEvent.ENTRY_REMOVED, - self._make_entry_handler(DashboardEvent.ENTRY_REMOVED), - ), - async_add_listener( - DashboardEvent.ENTRY_UPDATED, - self._make_entry_handler(DashboardEvent.ENTRY_UPDATED), - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, self._on_importable_added - ), - async_add_listener( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - self._on_importable_removed, - ), - ] - - def _on_entry_state_changed(self, event: Event) -> None: - """Handle entry state change event.""" - entry = event.data["entry"] - state = event.data["state"] - self._safe_send_message( - { - "event": DashboardEvent.ENTRY_STATE_CHANGED, - "data": { - "filename": entry.filename, - "name": entry.name, - "state": entry_state_to_bool(state), - }, - } - ) - - def _make_entry_handler( - self, event_type: DashboardEvent - ) -> Callable[[Event], None]: - """Create an entry event handler.""" - - def handler(event: Event) -> None: - self._safe_send_message( - {"event": event_type, "data": {"device": event.data["entry"].to_dict()}} - ) - - return handler - - def _on_importable_added(self, event: Event) -> None: - """Handle importable device added event.""" - # Don't send if device is already configured - device_name = event.data.get("device", {}).get("name") - if device_name and DASHBOARD.entries.get_by_name(device_name): - return - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_ADDED, "data": event.data} - ) - - def _on_importable_removed(self, event: Event) -> None: - """Handle importable device removed event.""" - self._safe_send_message( - {"event": DashboardEvent.IMPORTABLE_DEVICE_REMOVED, "data": event.data} - ) - - def _safe_send_message(self, message: dict[str, Any]) -> None: - """Send a message to the WebSocket client, ignoring closed errors.""" - with contextlib.suppress(tornado.websocket.WebSocketClosedError): - self.write_message(json.dumps(message)) - - def on_message(self, message: str) -> None: - """Handle incoming WebSocket messages.""" - _LOGGER.debug("WebSocket received message: %s", message) - try: - data = json.loads(message) - except json.JSONDecodeError as err: - _LOGGER.debug("Failed to parse WebSocket message: %s", err) - return - - event = data.get("event") - _LOGGER.debug("WebSocket message event: %s", event) - if event == DashboardEvent.PING: - # Send pong response for client ping - _LOGGER.debug("Received client ping, sending pong") - self._safe_send_message({"event": DashboardEvent.PONG}) - elif event == DashboardEvent.REFRESH: - # Signal the polling loop to refresh immediately - _LOGGER.debug("Received refresh request, signaling polling loop") - DASHBOARD_SUBSCRIBER.request_refresh() - - def on_close(self) -> None: - """Handle WebSocket close.""" - # Unsubscribe from dashboard updates - if self._dashboard_unsubscribe: - self._dashboard_unsubscribe() - self._dashboard_unsubscribe = None - - # Unsubscribe from events - for remove_listener in self._event_listeners or []: - remove_listener() - - _LOGGER.debug("Dashboard status WebSocket closed") - - -class SerialPortRequestHandler(BaseHandler): - @authenticated - async def get(self) -> None: - ports = await asyncio.get_running_loop().run_in_executor(None, get_serial_ports) - data = [] - for port in ports: - desc = port.description - if port.path == "/dev/ttyAMA0": - desc = "UART pins on GPIO header" - split_desc = desc.split(" - ") - if len(split_desc) == 2 and split_desc[0] == split_desc[1]: - # Some serial ports repeat their values - desc = split_desc[0] - data.append({"port": port.path, "desc": desc}) - data.append({"port": "OTA", "desc": "Over-The-Air"}) - data.sort(key=lambda x: x["port"], reverse=True) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - - -class WizardRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome import wizard - - kwargs = { - k: v - for k, v in json.loads(self.request.body.decode()).items() - if k - in ( - "type", - "name", - "platform", - "board", - "ssid", - "psk", - "password", - "file_content", - ) - } - if not kwargs["name"]: - self.set_status(422) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Name is required"})) - return - - if "type" not in kwargs: - # Default to basic wizard type for backwards compatibility - kwargs["type"] = "basic" - - kwargs["friendly_name"] = kwargs["name"] - kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - if kwargs["type"] == "basic": - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() - elif kwargs["type"] == "upload": - try: - kwargs["file_text"] = base64.b64decode(kwargs["file_content"]).decode( - "utf-8" - ) - except (binascii.Error, UnicodeDecodeError): - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": "The uploaded file is not correctly encoded."}) - ) - return - elif kwargs["type"] != "empty": - self.set_status(422) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": f"Invalid wizard type specified: {kwargs['type']}"} - ) - ) - return - filename = f"{kwargs['name']}.yaml" - destination = settings.rel_path(filename) - - # Check if destination file already exists - if destination.exists(): - self.set_status(409) # Conflict status code - self.set_header("content-type", "application/json") - self.write( - json.dumps({"error": f"Configuration file '{filename}' already exists"}) - ) - self.finish() - return - - success = wizard.wizard_write(path=destination, **kwargs) - if success: - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() - else: - self.set_status(500) - self.set_header("content-type", "application/json") - self.write( - json.dumps( - {"error": "Failed to write configuration, see logs for details"} - ) - ) - self.finish() - - -class ImportRequestHandler(BaseHandler): - @authenticated - def post(self) -> None: - from esphome.components.dashboard_import import import_config - - dashboard = DASHBOARD - args = json.loads(self.request.body.decode()) - try: - name = args["name"] - friendly_name = args.get("friendly_name") - encryption = args.get("encryption", False) - - imported_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == name - ), - None, - ) - - if imported_device is not None: - network = imported_device.network - if friendly_name is None: - friendly_name = imported_device.friendly_name - else: - network = const.CONF_WIFI - - import_config( - settings.rel_path(f"{name}.yaml"), - name, - friendly_name, - args["project_name"], - args["package_import_url"], - network, - encryption, - ) - # Make sure the device gets marked online right away - dashboard.ping_request.set() - except FileExistsError: - self.set_status(500) - self.write("File already exists") - return - except ValueError as e: - _LOGGER.error(e) - self.set_status(422) - self.write("Invalid package url") - return - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": f"{name}.yaml"})) - self.finish() - - -class IgnoreDeviceRequestHandler(BaseHandler): - @authenticated - async def post(self) -> None: - dashboard = DASHBOARD - try: - args = json.loads(self.request.body.decode()) - device_name = args["name"] - ignore = args["ignore"] - except (json.JSONDecodeError, KeyError): - self.set_status(400) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Invalid payload"})) - return - - ignored_device = next( - ( - res - for res in dashboard.import_result.values() - if res.device_name == device_name - ), - None, - ) - - if ignored_device is None: - self.set_status(404) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Device not found"})) - return - - if ignore: - dashboard.ignored_devices.add(ignored_device.device_name) - else: - dashboard.ignored_devices.discard(ignored_device.device_name) - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, dashboard.save_ignored_devices) - - self.set_status(204) - self.finish() - - -class DownloadListRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - loop = asyncio.get_running_loop() - try: - downloads_json = await loop.run_in_executor(None, self._get, configuration) - except vol.Invalid as exc: - _LOGGER.exception("Error while fetching downloads", exc_info=exc) - self.send_error(404) - return - if downloads_json is None: - _LOGGER.error("Configuration %s not found", configuration) - self.send_error(404) - return - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(downloads_json) - self.finish() - - def _get(self, configuration: str | None = None) -> dict[str, Any] | None: - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - return None - - try: - config = yaml_util.load_yaml(settings.rel_path(configuration)) - - if const.CONF_EXTERNAL_COMPONENTS in config: - from esphome.components.external_components import ( - do_external_components_pass, - ) - - do_external_components_pass(config) - except vol.Invalid: - _LOGGER.info("Could not parse `external_components`, skipping") - - from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS - - downloads: list[dict[str, Any]] = [] - platform: str = storage_json.target_platform.lower() - - if platform.upper() in ESP32_VARIANTS: - platform = "esp32" - elif platform in ( - const.PLATFORM_RTL87XX, - const.PLATFORM_BK72XX, - const.PLATFORM_LN882X, - ): - platform = "libretiny" - - try: - module = importlib.import_module(f"esphome.components.{platform}") - get_download_types = module.get_download_types - except AttributeError as exc: - raise ValueError(f"Unknown platform {platform}") from exc - downloads = get_download_types(storage_json) - return json.dumps(downloads) - - -class DownloadBinaryRequestHandler(BaseHandler): - def _load_file(self, path: str, compressed: bool) -> bytes: - """Load a file from disk and compress it if requested.""" - with Path(path).open("rb") as f: - data = f.read() - if compressed: - return gzip.compress(data, 9) - return data - - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Download a binary file.""" - loop = asyncio.get_running_loop() - compressed = self.get_argument("compressed", "0") == "1" - - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - self.send_error(404) - return - - # fallback to type=, but prioritize file= - file_name = self.get_argument("type", None) - file_name = self.get_argument("file", file_name) - if file_name is None or not file_name.strip(): - self.send_error(400) - return - # get requested download name, or build it based on filename - download_name = self.get_argument( - "download", - f"{storage_json.name}-{file_name}", - ) - - if storage_json.firmware_bin_path is None: - self.send_error(404) - return - - base_dir = storage_json.firmware_bin_path.parent.resolve() - path = base_dir.joinpath(file_name).resolve() - try: - path.relative_to(base_dir) - except ValueError: - self.send_error(403) - return - - if not path.is_file(): - args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)] - rc, stdout, _ = await async_run_system_command(args) - - if rc != 0: - self.send_error(404 if rc == 2 else 500) - return - - idedata = toolchain.IDEData(json.loads(stdout)) - - found = False - for image in idedata.extra_flash_images: - if image.path.as_posix().endswith(file_name): - path = image.path - download_name = file_name - found = True - break - - if not found: - self.send_error(404) - return - - download_name = download_name + ".gz" if compressed else download_name - - self.set_header("Content-Type", "application/octet-stream") - self.set_header( - "Content-Disposition", f'attachment; filename="{download_name}"' - ) - self.set_header("Cache-Control", "no-cache") - if not Path(path).is_file(): - self.send_error(404) - return - - data = await loop.run_in_executor(None, self._load_file, path, compressed) - self.write(data) - - self.finish() - - -class EsphomeVersionHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.set_header("Content-Type", "application/json") - self.write(json.dumps({"version": const.__version__})) - self.finish() - - -class ListDevicesHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - self.write(json.dumps(build_device_list_response(dashboard, entries))) - - -class MainRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - begin = bool(self.get_argument("begin", False)) - if settings.using_password: - # Simply accessing the xsrf_token sets the cookie for us - self.xsrf_token # pylint: disable=pointless-statement # noqa: B018 - else: - self.clear_cookie("_xsrf") - - self.render( - "index.template.html", - begin=begin, - **template_args(), - login_enabled=settings.using_password, - ) - - -class PrometheusServiceDiscoveryHandler(BaseHandler): - @authenticated - async def get(self) -> None: - dashboard = DASHBOARD - await dashboard.entries.async_request_update_entries() - entries = dashboard.entries.async_all() - self.set_header("content-type", "application/json") - sd = [] - for entry in entries: - if entry.web_port is None: - continue - labels = { - "__meta_name": entry.name, - "__meta_esp_platform": entry.target_platform, - "__meta_esphome_version": entry.storage.esphome_version, - } - for integration in entry.storage.loaded_integrations: - labels[f"__meta_integration_{integration}"] = "true" - sd.append( - { - "targets": [ - f"{entry.address}:{entry.web_port}", - ], - "labels": labels, - } - ) - self.write(json.dumps(sd)) - - -class BoardsRequestHandler(BaseHandler): - @authenticated - def get(self, platform: str) -> None: - # filter all ESP32 variants by requested platform - if platform.startswith("esp32"): - from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS - - boards = { - k: v - for k, v in ESP32_BOARDS.items() - if v[const.KEY_VARIANT] == platform.upper() - } - elif platform == const.PLATFORM_ESP8266: - from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS - - boards = ESP8266_BOARDS - elif platform == const.PLATFORM_RP2040: - from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS - - boards = RP2040_BOARDS - elif platform == const.PLATFORM_BK72XX: - from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS - - boards = BK72XX_BOARDS - elif platform == const.PLATFORM_LN882X: - from esphome.components.ln882x.boards import BOARDS as LN882X_BOARDS - - boards = LN882X_BOARDS - elif platform == const.PLATFORM_RTL87XX: - from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS - - boards = RTL87XX_BOARDS - else: - raise ValueError(f"Unknown platform {platform}") - - # map to a {board_name: board_title} dict - platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()} - # sort by board title - boards_items = sorted(platform_boards.items(), key=lambda item: item[1]) - output = [{"items": dict(boards_items)}] - - self.set_header("content-type", "application/json") - self.write(json.dumps(output)) - - -class PingRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - dashboard = DASHBOARD - dashboard.ping_request.set() - if settings.status_use_mqtt: - dashboard.mqtt_ping_request.set() - self.set_header("content-type", "application/json") - - self.write( - json.dumps( - { - entry.filename: entry_state_to_bool(entry.state) - for entry in dashboard.entries.async_all() - } - ) - ) - - -class InfoRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - yaml_path = settings.rel_path(configuration) - dashboard = DASHBOARD - entry = dashboard.entries.get(yaml_path) - - if not entry or entry.storage is None: - self.set_status(404) - return - - self.set_header("content-type", "application/json") - self.write(entry.storage.to_json()) - - -class EditRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - """Get the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - content = await loop.run_in_executor( - None, self._read_file, filename, configuration - ) - if content is not None: - self.set_header("Content-Type", "application/yaml") - self.write(content) - - def _read_file(self, filename: str, configuration: str) -> bytes | None: - """Read a file and return the content as bytes.""" - try: - with Path(filename).open(encoding="utf-8") as f: - return f.read() - except FileNotFoundError: - if configuration in const.SECRETS_FILES: - return "" - self.set_status(404) - return None - - @authenticated - @bind_config - async def post(self, configuration: str | None = None) -> None: - """Write the content of a file.""" - if not configuration.endswith((".yaml", ".yml")): - self.send_error(404) - return - - filename = settings.rel_path(configuration) - if filename.resolve().parent != settings.absolute_config_dir: - self.send_error(404) - return - - loop = asyncio.get_running_loop() - await loop.run_in_executor(None, write_file, filename, self.request.body) - # Ensure the StorageJSON is updated as well - DASHBOARD.entries.async_schedule_storage_json_update(filename) - self.set_status(200) - - -class ArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - storage_path = ext_storage_path(configuration) - - archive_path = archive_storage_path() - mkdir_p(archive_path) - shutil.move(config_file, archive_path / configuration) - - storage_json = StorageJSON.load(storage_path) - if storage_json is not None and storage_json.build_path: - # Delete build folder (if exists) - shutil.rmtree(storage_json.build_path, ignore_errors=True) - - -class UnArchiveRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration: str | None = None) -> None: - config_file = settings.rel_path(configuration) - archive_path = archive_storage_path() - shutil.move(archive_path / configuration, config_file) - - -class LoginHandler(BaseHandler): - def get(self) -> None: - if is_authenticated(self): - self.redirect("./") - else: - self.render_login_page() - - def render_login_page(self, error: str | None = None) -> None: - self.render( - "login.template.html", - error=error, - ha_addon=settings.using_ha_addon_auth, - has_username=bool(settings.username), - **template_args(), - ) - - def _make_supervisor_auth_request(self) -> Response: - """Make a request to the supervisor auth endpoint.""" - import requests - - headers = {"X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN")} - data = { - "username": self.get_argument("username", ""), - "password": self.get_argument("password", ""), - } - return requests.post( - "http://supervisor/auth", headers=headers, json=data, timeout=30 - ) - - async def post_ha_addon_login(self) -> None: - loop = asyncio.get_running_loop() - try: - req = await loop.run_in_executor(None, self._make_supervisor_auth_request) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.warning("Error during Hass.io auth request: %s", err) - self.set_status(500) - self.render_login_page(error="Internal server error") - return - - if req.status_code == 200: - self._set_authenticated() - self.redirect("/") - return - self.set_status(401) - self.render_login_page(error="Invalid username or password") - - def _set_authenticated(self) -> None: - """Set the authenticated cookie.""" - self.set_secure_cookie(AUTH_COOKIE_NAME, COOKIE_AUTHENTICATED_YES) - - def post_native_login(self) -> None: - username = self.get_argument("username", "") - password = self.get_argument("password", "") - if settings.check_password(username, password): - self._set_authenticated() - self.redirect("./") - return - error_str = ( - "Invalid username or password" if settings.username else "Invalid password" - ) - self.set_status(401) - self.render_login_page(error=error_str) - - async def post(self): - if settings.using_ha_addon_auth: - await self.post_ha_addon_login() - else: - self.post_native_login() - - -class LogoutHandler(BaseHandler): - @authenticated - def get(self) -> None: - self.clear_cookie(AUTH_COOKIE_NAME) - self.redirect("./login") - - -class SecretKeysRequestHandler(BaseHandler): - @authenticated - def get(self) -> None: - filename = None - - for secret_filename in const.SECRETS_FILES: - relative_filename = settings.rel_path(secret_filename) - if relative_filename.is_file(): - filename = relative_filename - break - - if filename is None: - self.send_error(404) - return - - secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False)) - - self.set_header("content-type", "application/json") - self.write(json.dumps(secret_keys)) - - -class SafeLoaderIgnoreUnknown(FastestAvailableSafeLoader): - def ignore_unknown(self, node: Node) -> str: - return f"{node.tag} {node.value}" - - def construct_yaml_binary(self, node: Node) -> str: - return super().construct_yaml_binary(node).decode("ascii") - - -SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown) -SafeLoaderIgnoreUnknown.add_constructor( - "tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary -) - - -class JsonConfigRequestHandler(BaseHandler): - @authenticated - @bind_config - async def get(self, configuration: str | None = None) -> None: - filename = settings.rel_path(configuration) - if not filename.is_file(): - self.send_error(404) - return - - args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"] - - rc, stdout, stderr = await async_run_system_command(args) - - if rc != 0: - self.set_status(422) - self.write(stderr) - return - - data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - self.finish() - - -def get_base_frontend_path() -> Path: - if ENV_DEV not in os.environ: - import esphome_dashboard - - return esphome_dashboard.where() - - static_path = os.environ[ENV_DEV] - if not static_path.endswith("/"): - static_path += "/" - - # This path can be relative, so resolve against the root or else templates don't work - path = Path.cwd() / static_path / "esphome_dashboard" - return path.resolve() - - -def get_static_path(*args: Iterable[str]) -> Path: - return get_base_frontend_path() / "static" / Path(*args) - - -@functools.cache -def get_static_file_url(name: str) -> str: - base = f"./static/{name}" - - if ENV_DEV in os.environ: - return base - - # Module imports can't deduplicate if stuff added to url - if name == "js/esphome/index.js": - import esphome_dashboard - - return base.replace("index.js", esphome_dashboard.entrypoint()) - - path = get_static_path(name) - hash_ = hashlib.md5(path.read_bytes()).hexdigest()[:8] - return f"{base}?hash={hash_}" - - -def make_app(debug: bool | None = None) -> tornado.web.Application: - if debug is None: - debug = get_bool_env(ENV_DEV) - - def log_function(handler: tornado.web.RequestHandler) -> None: - if handler.get_status() < 400: - log_method = access_log.info - - if isinstance(handler, SerialPortRequestHandler) and not debug: - return - if isinstance(handler, PingRequestHandler) and not debug: - return - elif handler.get_status() < 500: - log_method = access_log.warning - else: - log_method = access_log.error - - request_time = 1000.0 * handler.request.request_time() - # pylint: disable=protected-access - log_method( - "%d %s %.2fms", - handler.get_status(), - handler._request_summary(), - request_time, - ) - - class StaticFileHandler(tornado.web.StaticFileHandler): - def get_cache_time( - self, path: str, modified: datetime.datetime | None, mime_type: str - ) -> int: - """Override to customize cache control behavior.""" - if debug: - return 0 - # Assets that are hashed have ?hash= in the URL, all javascript - # filenames hashed so we can cache them for a long time - if "hash" in self.request.arguments or "/javascript" in mime_type: - return self.CACHE_MAX_AGE - return super().get_cache_time(path, modified, mime_type) - - app_settings = { - "debug": debug, - "cookie_secret": settings.cookie_secret, - "log_function": log_function, - "websocket_ping_interval": 30.0, - "template_path": get_base_frontend_path(), - "xsrf_cookies": settings.using_password, - } - rel = settings.relative_url - return tornado.web.Application( - [ - (f"{rel}", MainRequestHandler), - (f"{rel}login", LoginHandler), - (f"{rel}logout", LogoutHandler), - (f"{rel}logs", EsphomeLogsHandler), - (f"{rel}upload", EsphomeUploadHandler), - (f"{rel}run", EsphomeRunHandler), - (f"{rel}compile", EsphomeCompileHandler), - (f"{rel}validate", EsphomeValidateHandler), - (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), - (f"{rel}clean-all", EsphomeCleanAllHandler), - (f"{rel}clean", EsphomeCleanHandler), - (f"{rel}vscode", EsphomeVscodeHandler), - (f"{rel}ace", EsphomeAceEditorHandler), - (f"{rel}update-all", EsphomeUpdateAllHandler), - (f"{rel}info", InfoRequestHandler), - (f"{rel}edit", EditRequestHandler), - (f"{rel}downloads", DownloadListRequestHandler), - (f"{rel}download.bin", DownloadBinaryRequestHandler), - (f"{rel}serial-ports", SerialPortRequestHandler), - (f"{rel}ping", PingRequestHandler), - (f"{rel}delete", ArchiveRequestHandler), - (f"{rel}undo-delete", UnArchiveRequestHandler), - (f"{rel}archive", ArchiveRequestHandler), - (f"{rel}unarchive", UnArchiveRequestHandler), - (f"{rel}wizard", WizardRequestHandler), - (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), - (f"{rel}devices", ListDevicesHandler), - (f"{rel}events", DashboardEventsWebSocket), - (f"{rel}import", ImportRequestHandler), - (f"{rel}secret_keys", SecretKeysRequestHandler), - (f"{rel}json-config", JsonConfigRequestHandler), - (f"{rel}rename", EsphomeRenameHandler), - (f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler), - (f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler), - (f"{rel}version", EsphomeVersionHandler), - (f"{rel}ignore-device", IgnoreDeviceRequestHandler), - ], - **app_settings, - ) - - -def start_web_server( - app: tornado.web.Application, - socket: str | None, - address: str | None, - port: int | None, - config_dir: str, -) -> None: - """Start the web server listener.""" - - trash_path = trash_storage_path() - if trash_path.is_dir() and trash_path.exists(): - _LOGGER.info("Renaming 'trash' folder to 'archive'") - archive_path = archive_storage_path() - shutil.move(trash_path, archive_path) - - if socket is None: - _LOGGER.info( - "Starting dashboard web server on http://%s:%s and configuration dir %s...", - address, - port, - config_dir, - ) - app.listen(port, address) - return - - _LOGGER.info( - "Starting dashboard web server on unix socket %s and configuration dir %s...", - socket, - config_dir, - ) - server = tornado.httpserver.HTTPServer(app) - socket = tornado.netutil.bind_unix_socket(socket, mode=0o666) - server.add_socket(socket) diff --git a/esphome/espidf/clang_tidy.py b/esphome/espidf/clang_tidy.py index d3f4d151c2..88ecda60b9 100644 --- a/esphome/espidf/clang_tidy.py +++ b/esphome/espidf/clang_tidy.py @@ -147,9 +147,9 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None: from esphome.core import CORE CORE.name = TIDY_PROJECT_NAME - # config_path's parent is the data dir root: the IDF install lives at - # ``/.esphome/idf`` -- keep it beside (not inside) the per-run - # project dir so clearing the project doesn't force an IDF re-download. + # config_path's parent is the data dir root for per-run artifacts (idedata, + # converted pio_components). The IDF install is in the global cache dir, + # independent of this path. CORE.config_path = work_dir.parent / "tidy.yaml" CORE.build_path = work_dir esp32 = CORE.data.setdefault(KEY_ESP32, {}) diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index cfd42916b2..e9ec170a5e 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -1,163 +1,42 @@ -from collections import deque -from collections.abc import Callable -from dataclasses import dataclass, field -import glob -import hashlib -import itertools -import json +"""ESP-IDF backend for the shared PlatformIO library converter. + +The toolchain-agnostic resolution/download/caching pipeline lives in +``esphome.platformio.library``; this module only adds the ESP-IDF specifics: +emitting an ``idf_component_register`` ``CMakeLists.txt`` + ``idf_component.yml`` +for each resolved library, running any PlatformIO ``extraScript``, and the +ESP-IDF platform/framework compatibility defaults. +""" + import logging import os from pathlib import Path -import re -import tempfile -from typing import Any, TypeVar -from urllib.parse import urlparse, urlsplit, urlunsplit -from esphome import git, yaml_util from esphome.core import CORE, Library -from esphome.espidf.framework import archive_extract_all, download_from_mirrors, rmdir from esphome.helpers import write_file_if_changed +from esphome.platformio.library import ( + DEFAULT_BUILD_FLAGS, + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + ESPHOME_DATA_EXTRA_CMAKE_KEY, + ESPHOME_DATA_KEY, + SRC_FILE_EXTENSIONS, + ConvertedLibrary as IDFComponent, + LibraryBackend, + PathType, + collect_filtered_files, + convert_libraries, + ensure_list, + split_list_by_condition, +) _LOGGER = logging.getLogger(__name__) -PathType = str | os.PathLike - -# -# Constants from platformio -# - -FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") -DEFAULT_BUILD_SRC_FILTER = ( - "+<*> -<.git/> -<.svn/> - - - -" -) -DEFAULT_BUILD_SRC_DIRS = "src" -DEFAULT_BUILD_INCLUDE_DIR = "include" -DEFAULT_BUILD_FLAGS = [] -SRC_FILE_EXTENSIONS = [ - ".c", - ".cpp", - ".cc", - ".cxx", - ".c++", - ".S", - ".spp", - ".SPP", - ".sx", - ".s", - ".asm", - ".ASM", -] - ESP32_PLATFORM = "espressif32" -DOMAIN = "pio_components" - -ESPHOME_DATA_KEY = "ESPHOME" -ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" -class Source: - def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path: - raise NotImplementedError - - -class URLSource(Source): - def __init__(self, url: str): - self.url = url - - 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 - # extraction is correctly detected and re-run on the next invocation, - # and lets us extract directly into ``path`` — avoiding a - # post-extraction rename that races with antivirus on Windows. - extracted_marker = path / ".esphome_extracted" - if not extracted_marker.is_file() or force: - rmdir(path, msg=f"Clean up library directory {path}") - - # Download in temporary file - with tempfile.NamedTemporaryFile() as tmp: - _LOGGER.info("Downloading %s ...", self.url) - _LOGGER.debug("Location: %s", path) - - download_from_mirrors([self.url], {}, tmp.file) - - _LOGGER.debug("Extracting archive to %s ...", path) - archive_extract_all(tmp.file, path) - extracted_marker.touch() - return path - - def __str__(self): - return self.url - - -class GitSource(Source): - def __init__(self, url: str, ref: str | None): - self.url = url - self.ref = ref - - 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=f"{DOMAIN}/{salt}" if salt else DOMAIN, - submodules=[], - subpath=Path(dir_suffix), - ) - return path - - def __str__(self): - return f"{self.url}#{self.ref}" if self.ref else self.url - - -class InvalidIDFComponent(Exception): - pass - - -class IDFComponent: - def __init__(self, name: str, version: str, source: Source | None): - self.name = name - self.version = version - self.source = source - self.data = {} - self.dependencies: list[IDFComponent] = [] - self._path: Path | None = None - - def __str__(self): - return f"{self.name}@{self.version}={self.source}" - - @property - def path(self) -> Path: - if self._path is None: - raise RuntimeError(f"path not set for component {self}") - return self._path - - @path.setter - def path(self, value: Path) -> None: - self._path = value - - def get_sanitized_name(self): - return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) - - def get_require_name(self): - return self.get_sanitized_name().replace("/", "__") - - 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, salt=salt - ) +def _idf_framework() -> str: + """The framework token an ESP-IDF library manifest is expected to declare.""" + return "arduino" if CORE.using_arduino else "espidf" def _apply_extra_script(component: IDFComponent) -> None: @@ -190,119 +69,6 @@ def _apply_extra_script(component: IDFComponent) -> None: component.data["build"]["flags"] = flags -T = TypeVar("T") - - -def _ensure_list(obj: T | list[T]) -> list[T]: - """ - Convert an object to a list if it isn't already a list. - - Args: - obj: Object that may or may not already be a list. - - Returns: - list[T]: The original list if ``obj`` is a list, otherwise a single-item - list containing ``obj``. - """ - return [obj] if not isinstance(obj, list) else obj - - -def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: - """ - Convert owner and package name to a standardized component name. - - This function combines owner and package name with a forward slash when - both are provided, otherwise returns just the package name. - - Args: - owner: The owner/username of the package (can be None) - pkgname: The name of the package - - Returns: - str: The standardized component name in "owner/pkgname" format or just "pkgname" - """ - return f"{owner}/{pkgname}" if owner else pkgname - - -def _collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: - """ - Recursively match files in a directory according to include/exclude patterns. - - This function processes a list of filter strings that indicate which files - to include or exclude. Each filter is parsed into patterns with a sign: - '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' - are normalized to include all their contents recursively. - - Args: - src_dir (PathType): Root directory to search within. - src_filters (list[str]): List of filter strings, which may contain multiple - patterns. Each pattern can start with '+' or '-' to indicate inclusion - or exclusion. - - Returns: - list[str]: List of matched file paths as strings. Only files (not directories) - are returned, even if a directory matches a pattern. - """ - matches = list( - itertools.chain.from_iterable( - FILTER_REGEX.findall(src_filter) for src_filter in src_filters - ) - ) - - selected = set() - - for sign, pattern in matches: - pattern = pattern.strip() - - if pattern.endswith("/"): - pattern = pattern.rstrip("/") + "/**" - - # glob.escape has no pathlib equivalent and the matcher works on raw - # path strings, so PTH118/PTH207 don't apply here. - full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 - - matched = [] - for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 - if not Path(item).is_dir(): - matched.append(item) - else: - # PlatformIO quirk: a directory matched with "*" should include all its - # nested files and subdirectories, not just the directory itself. - for root, _, files in os.walk(item): - matched.extend([str(Path(root) / f) for f in files]) - - if sign == "+": - selected.update(matched) - elif sign == "-": - selected.difference_update(matched) - - return [r for r in selected if Path(r).is_file()] - - -def _split_list_by_condition( - items: list[str], match_fn: Callable[[str], str | None] -) -> tuple[list[str], list[str]]: - """ - Splits a list into two lists based on a matching function. - - Args: - items: List of items to split. - match_fn: Function that returns a value for items that should go into the "matched" list. - - Returns: - A tuple (matched, non_matched) - """ - matched = [] - non_matched = [] - for item in items: - result = match_fn(item) - if result: - matched.append(result) - else: - non_matched.append(item) - return matched, non_matched - - def generate_cmakelists_txt(component: IDFComponent) -> str: """ Generate a CMakeLists.txt file for an ESP-IDF component. @@ -333,15 +99,15 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_include_dir = component.data.get("build", {}).get( "includeDir", DEFAULT_BUILD_INCLUDE_DIR ) - build_src_filter = _ensure_list( + build_src_filter = ensure_list( component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER) ) - build_flags = _ensure_list( + build_flags = ensure_list( component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) # List all sources files - build_src_files = _collect_filtered_files( + build_src_files = collect_filtered_files( component.path / Path(build_src_dir), build_src_filter ) @@ -361,13 +127,13 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Handle build flags - include_dir_flags, build_flags = _split_list_by_condition( + include_dir_flags, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-I") else None ) - link_directories, build_flags = _split_list_by_condition( + link_directories, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-L") else None ) - link_libraries, build_flags = _split_list_by_condition( + link_libraries, build_flags = split_list_by_condition( build_flags, lambda a: a[2:].strip() if a.startswith("-l") else None ) @@ -379,7 +145,7 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: ] # Split build_flags list into private and public lists - private_build_flags, public_build_flags = _split_list_by_condition( + private_build_flags, public_build_flags = split_list_by_condition( build_flags, lambda a: a if a.startswith("-W") else None ) @@ -453,6 +219,8 @@ def generate_idf_component_yml(component: IDFComponent) -> str: Returns: YAML string representation of ESP-IDF component configuration """ + from esphome import yaml_util + data = {} description = component.data.get("description") @@ -477,410 +245,25 @@ def generate_idf_component_yml(component: IDFComponent) -> str: return yaml_util.dump(data) -def _check_library_data(data: dict): - """ - Check if a library data is compatible with the ESP-IDF framework. - - A platform mismatch (e.g. an AVR-only library on ESP32) raises - ``InvalidIDFComponent`` so the caller skips the library. A framework - mismatch only logs a warning — PIO manifests often understate the - frameworks they actually compile under, and IDF (unlike PIO's - ``lib_compat_mode``) has no opt-out, so we include the library anyway. - - Args: - data: PIO library manifest dict being processed. - - Raises: - InvalidIDFComponent: If the library does not support the ESP32 platform. - """ - platforms = data.get("platforms", "*") - if isinstance(platforms, str): - platforms = [a.strip() for a in platforms.split(",")] - platforms = _ensure_list(platforms) - - # Check if library supports ESP-IDF platform - valid_platforms = "*" in platforms or ESP32_PLATFORM in platforms - - if not valid_platforms: - raise InvalidIDFComponent(f"Unsupported library platforms: {platforms}") - - frameworks = data.get("frameworks", "*") - if isinstance(frameworks, str): - frameworks = [a.strip() for a in frameworks.split(",")] - frameworks = _ensure_list(frameworks) - - # Check if library declares the active framework. PIO library manifests - # often list only "arduino" even when the library actually compiles fine - # under ESP-IDF, and IDF (unlike PIO with `lib_compat_mode`) has no way to - # opt out of the check. Warn instead of failing so the user isn't forced to - # fork the library to fix the manifest. - framework = "arduino" if CORE.using_arduino else "espidf" - valid_framework = "*" in frameworks or framework in frameworks - - if not valid_framework: - _LOGGER.warning( - "Library %s declares frameworks %s that do not include '%s'; including anyway", - data.get("name", ""), - frameworks, - framework, - ) - - -def _parse_library_json(library_json_path: PathType): - """ - Load and parse a JSON file describing a library. - - Args: - library_json_path (PathType): Path to the JSON file. - - Returns: - dict: Parsed JSON content as a Python dictionary. - """ - with Path(library_json_path).open(encoding="utf8") as fp: - return json.load(fp) - - -def _parse_library_properties(library_properties_path: PathType): - """ - Parse a key-value platformio .properties style file into a dictionary. - - Args: - library_properties_path (PathType): Path to the properties file. - - Returns: - dict[str, str]: Mapping of parsed property keys to values. - """ - with Path(library_properties_path).open(encoding="utf8") as fp: - data = {} - for line in fp.read().splitlines(): - line = line.strip() - if not line or "=" not in line: - continue - # skip comments - if line.startswith("#"): - continue - key, value = line.split("=", 1) - if not value.strip(): - continue - data[key.strip()] = value.strip() - return data - - -def _make_registry_client() -> Any: - """Create a minimal PlatformIO registry client with no system filtering. - - ``is_system_compatible`` is forced True so version selection is driven purely - by the requested version requirements -- ESP-IDF/target compatibility is - handled elsewhere, not by the PlatformIO registry. - """ - from platformio.package.manager._registry import PackageManagerRegistryMixin - - class _Registry(PackageManagerRegistryMixin): - def __init__(self) -> None: - self._registry_client = None - self.pkg_type = "library" - - @staticmethod - def is_system_compatible(value: Any, custom_system: Any = None) -> bool: - return True - - return _Registry() - - -def _resolve_registry_version( - owner: str | None, pkgname: str, requirements: set[str] -) -> tuple[str, str, str, str]: - """Resolve a registry package to the single highest version satisfying ALL - the given requirements; return ``(owner, name, version, download_url)``. - - Intersecting every requirement (rather than resolving each consumer in - isolation) makes the result independent of processing order and guarantees - no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as - both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. - """ - from platformio.package.meta import PackageSpec - - registry = _make_registry_client() - package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) - owner = package["owner"]["username"] - name = package["name"] - - # Chaining the per-requirement filter intersects all constraints. - versions = package.get("versions") or [] - for requirement in sorted(requirements): - versions = registry.get_compatible_registry_versions( - versions, PackageSpec(owner=owner, name=name, requirements=requirement) - ) - if not versions: - raise RuntimeError( - f"No version of {owner}/{name} satisfies all requirements " - f"{sorted(requirements)} requested across the library tree" - ) - - best = registry.pick_best_registry_version(versions) - pkgfile = registry.pick_compatible_pkg_file(best["files"]) - if not pkgfile: - raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") - return owner, name, best["name"], pkgfile["download_url"] - - -def _normalize_dependencies(dependencies: Any) -> list[dict]: - """Normalize a library manifest's ``dependencies`` to a list of dicts. - - PIO's library.json accepts both the list-of-dicts form and the shorthand - dict form (``{"owner/Name": "version_spec"}``); normalize the latter so - callers see a uniform list. - """ - if not dependencies: - return [] - if isinstance(dependencies, dict): - normalized = [] - for raw_name, spec in dependencies.items(): - if "/" in raw_name: - owner, pkgname = raw_name.split("/", 1) - else: - owner, pkgname = None, raw_name - entry = {"name": pkgname, "owner": owner} - if isinstance(spec, dict): - entry.update(spec) - else: - entry["version"] = spec - normalized.append(entry) - return normalized - return [d for d in dependencies if isinstance(d, dict)] - - -@dataclass -class _LibNode: - """A node in the library dependency graph being resolved as a batch.""" - - key: str - is_git: bool - owner: str | None = None - pkgname: str | None = None - requirements: set[str] = field(default_factory=set) - url: str | None = None - ref: str | None = None - edges: set[str] = field(default_factory=set) - - -def _node_key( - name: str | None, version: str | None, repository: str | None -) -> tuple[str, bool, tuple[str | None, str | None]]: - """Return ``(key, is_git, locator)`` for a library or dependency spec. - - The key is derived from the *input* spec (the registry name as written, or - the git URL path), not the resolved canonical name. So a package referenced - inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps - to distinct keys and isn't deduplicated; ``generate_idf_components`` warns - about that after resolution rather than merging the nodes. - """ - if repository: - split_result = urlsplit(repository) - key = str(split_result.path).strip("/").removesuffix(".git") - ref = split_result.fragment.strip() or None - url = urlunsplit(split_result._replace(fragment="")) - return key, True, (url, ref) - if name and "/" in name: - owner, pkgname = name.split("/", 1) - else: - owner, pkgname = None, name - return name, False, (owner, pkgname) +def _emit_idf_component(component: IDFComponent) -> None: + """Write the ESP-IDF build files for a resolved library into its cache dir.""" + _apply_extra_script(component) + write_file_if_changed( + component.path / "CMakeLists.txt", + generate_cmakelists_txt(component), + ) + write_file_if_changed( + component.path / "idf_component.yml", + generate_idf_component_yml(component), + ) def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]: - """Resolve and convert a batch of PlatformIO libraries to IDF components. - - Resolves the whole set together rather than each library independently: it - walks the dependency graph collecting every version *requirement* per - component name, then resolves each name once to a single version satisfying - all of them. So a transitive dependency shared under - different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and - ``esp_wireguard``) becomes one component instead of two clashing - ``override_path`` entries -- order-independently, and without ever violating - a stated constraint. - - 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 "" + """Resolve and convert a batch of PlatformIO libraries to IDF components.""" + backend = LibraryBackend( + platform=ESP32_PLATFORM, + framework=_idf_framework(), + emit=_emit_idf_component, + cache_key="idf", ) - - 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) - nodes[key] = node - if is_git: - node.is_git = True - node.url, node.ref = locator - else: - node.owner, node.pkgname = locator - if version: - node.requirements.add(version) - return key - - 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 - # requirement set has grown since the last time, so every requirement in the - # graph is accounted for before conversion. - components: dict[str, IDFComponent] = {} - resolved_requirements: dict[str, frozenset[str]] = {} - top_level_keys = set(top_level) - worklist = deque(dict.fromkeys(top_level)) - while worklist: - key = worklist.popleft() - node = nodes[key] - - # A node is queued once per referring edge; skip the (uncached) registry - # lookup + download + dependency walk unless its requirement set grew - # since the last resolve. Requirements only ever grow, so this still - # converges the fixpoint and terminates dependency cycles. - requirements = frozenset(node.requirements) - if resolved_requirements.get(key) == requirements: - continue - resolved_requirements[key] = requirements - - if node.is_git: - component = IDFComponent(key, "*", GitSource(node.url, node.ref)) - else: - owner, name, version, url = _resolve_registry_version( - node.owner, node.pkgname, node.requirements - ) - component = IDFComponent( - _owner_pkgname_to_name(owner, name), version, URLSource(url) - ) - component.download(salt=salt) - - library_json_path = component.path / "library.json" - library_properties_path = component.path / "library.properties" - if library_json_path.is_file(): - component.data = _parse_library_json(library_json_path) - elif library_properties_path.is_file(): - component.data = _parse_library_properties(library_properties_path) - else: - raise RuntimeError( - f"Invalid PIO library {key}: missing library.json and " - "library.properties" - ) - - try: - _check_library_data(component.data) - except InvalidIDFComponent as e: - # Skip an incompatible transitive dependency, but fail fast if a - # top-level library the build explicitly requested is incompatible. - if key in top_level_keys: - raise RuntimeError( - f"Requested library {key} is not compatible with ESP-IDF: {e}" - ) from e - _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) - continue - components[key] = component - - # Requirements changed (we got past the short-circuit above), so - # (re)walk this component's dependencies. - node.edges = set() - for dependency in _normalize_dependencies(component.data.get("dependencies")): - if "name" not in dependency or "version" not in dependency: - continue - try: - _check_library_data(dependency) - 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 - try: - parsed = urlparse(dep_version) - if all([parsed.scheme, parsed.netloc]): - dep_url, dep_version = dep_version, None - except (TypeError, ValueError): - pass - dep_key = add_spec(dep_name, dep_version, dep_url) - node.edges.add(dep_key) - worklist.append(dep_key) - - # A git source wins over any registry version requested for the same - # component. That's intentional, but warn so a dropped registry pin isn't a - # silent surprise. - for node in nodes.values(): - if node.is_git and node.requirements: - _LOGGER.warning( - "Library %s is requested both from a git source (%s) and as " - "registry version(s) %s; using the git source.", - node.key, - node.url, - sorted(node.requirements), - ) - - # Two graph nodes that resolve to the same component name (e.g. a package - # referenced both bare and as ``owner/name``) are not deduplicated and can - # produce conflicting component definitions. Warn so it's not silent. - canonical_keys: dict[str, str] = {} - for node_key, component in components.items(): - canonical = component.get_sanitized_name() - if canonical_keys.setdefault(canonical, node_key) != node_key: - _LOGGER.warning( - "Library %s is referenced under multiple names (%s and %s); these " - "are not deduplicated. Reference it consistently as %s.", - canonical, - canonical_keys[canonical], - node_key, - canonical, - ) - - # Wire each component's dependencies to the single resolved instances, then - # regenerate build files. - for key, component in components.items(): - component.dependencies = [ - components[dep_key] - for dep_key in sorted(nodes[key].edges) - if dep_key in components - ] - for component in components.values(): - _apply_extra_script(component) - write_file_if_changed( - component.path / "CMakeLists.txt", - generate_cmakelists_txt(component), - ) - write_file_if_changed( - component.path / "idf_component.yml", - generate_idf_component_yml(component), - ) - - return [components[key] for key in top_level if key in components] + return convert_libraries(libraries, backend) diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 7213f2dfc0..810a63476f 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -9,6 +9,8 @@ import re import shutil import tempfile +import platformdirs + from esphome.config_validation import Version from esphome.core import CORE from esphome.framework_helpers import ( @@ -23,7 +25,7 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) -from esphome.helpers import get_str_env, write_file_if_changed +from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -73,17 +75,25 @@ ESP_IDF_CONSTRAINTS_MIRRORS = str_to_lst_of_str( ) -def _get_idf_tools_path() -> Path: +def get_idf_tools_path() -> Path: """ Get the path to the ESP-IDF tools directory. Returns: Path object pointing to the ESP-IDF tools directory """ - if "ESPHOME_ESP_IDF_PREFIX" in os.environ: - path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser() + # Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("") + # resolves to the CWD, which would install into (and let clean-all delete) + # the working directory by accident. + if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip(): + path = Path(prefix).expanduser() else: - path = CORE.data_dir / "idf" + # Machine-global so all projects share the multi-GB install instead of + # a per-config-directory copy. The user cache dir (not ~/.esphome) + # avoids colliding with data_dir when configs live in the home dir. + # appauthor=False drops the redundant \ segment on Windows + # (which otherwise repeats "esphome\esphome\") to keep the path short. + path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "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. @@ -131,7 +141,7 @@ def _check_windows_path_length() -> None: """ if platform.system() != "Windows" or _windows_long_paths_enabled(): return - tools_path = str(_get_idf_tools_path()) + tools_path = str(get_idf_tools_path()) projected = len(tools_path) + _TOOLCHAIN_NESTED_PATH_LEN if projected <= _WINDOWS_MAX_PATH: return @@ -145,10 +155,11 @@ def _check_windows_path_length() -> None: " fatal error: bits/c++config.h: No such file or directory\n" " cannot execute 'as': CreateProcess: No such file or directory\n" "To fix, either:\n" - " - Enable Windows long path support: set\n" - " HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled\n" - " to 1 and reboot, or\n" - " - Move your ESPHome project to a shorter path\n" + " - Enable Windows long path support, then reboot. In an elevated\n" + " PowerShell run:\n" + " Set-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem' LongPathsEnabled 1\n" + " Details: https://learn.microsoft.com/windows/win32/fileio/maximum-file-path-limitation\n" + " - Or set ESPHOME_ESP_IDF_PREFIX to a shorter path (e.g. C:\\ESPHome\\idf)\n" "Then delete the ESP-IDF tools directory above so the toolchain " "reinstalls cleanly.", tools_path, @@ -169,7 +180,7 @@ def _get_framework_path(version: str) -> Path: Returns: Path object pointing to the framework directory """ - return _get_idf_tools_path() / "frameworks" / f"{version}" + return get_idf_tools_path() / "frameworks" / f"{version}" def _get_python_env_path(version: str) -> Path: @@ -182,7 +193,7 @@ def _get_python_env_path(version: str) -> Path: Returns: Path object pointing to the Python environment directory """ - return _get_idf_tools_path() / "penvs" / f"{version}" + return get_idf_tools_path() / "penvs" / f"{version}" def _check_stamp(file: PathType, data: dict[str, str]) -> bool: @@ -553,7 +564,7 @@ def _check_esphome_idf_framework_install( # Logged every invocation (not just on install) so the user can verify the # override. A changed URL needs ``esphome clean-all`` to force a re-download # (``esphome clean`` only wipes the build dir, not the extracted framework - # under /idf/frameworks/). + # under the global install dir's ``frameworks/``). if source_url: _LOGGER.info("Using framework source override: %s", source_url) @@ -696,7 +707,7 @@ def _check_esp_idf_python_env_install( esp_idf_version = _get_idf_version(framework_path, env=env) constraint_file_path = ( - _get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" + get_idf_tools_path() / f"espidf.constraints.v{esp_idf_version}.txt" ) _LOGGER.debug("ESP-IDF version %s", esp_idf_version) @@ -787,7 +798,7 @@ def check_esp_idf_install( _check_windows_path_length() env = {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" targets = targets or ESPHOME_IDF_DEFAULT_TARGETS @@ -817,6 +828,54 @@ def check_esp_idf_install( return framework_path, python_env_path +def _ccache_env() -> dict[str, str]: + """Return ccache settings for ESP-IDF compiles. + + Enabled by default whenever the ``ccache`` binary is on PATH; set + ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under + the IDF tools path (the machine-global cache dir, or + ``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed + by ``esphome clean-all`` along with the framework. + + Depend mode keeps cache-miss overhead low (hashes the compiler's depfiles + instead of preprocessing). ``CCACHE_BASEDIR`` rewrites the per-build + absolute paths (generated ``sdkconfig`` include, etc.) so different devices + share framework cache entries; it is scoped to the build dir on purpose -- + a broader base would also rewrite the shared IDF path under the cache dir + and lose those hits. + + Only values the user has not already set in the environment are returned, so + a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected. + """ + # Honor an explicit choice already in the environment (opt-out or opt-in). + if "IDF_CCACHE_ENABLE" in os.environ: + if not get_bool_env("IDF_CCACHE_ENABLE"): + return {} + elif shutil.which("ccache") is None: + # ESP-IDF silently skips ccache without the binary; don't enable it. + return {} + + # ccache is enabled past here. build_path is set during preload for every + # config-loading command, so it being unset means a caller built the IDF env + # too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which + # would quietly cost cross-device cache hits). + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the ESP-IDF build " + "environment" + ) + + defaults = { + "IDF_CCACHE_ENABLE": "1", + "CCACHE_DIR": str(get_idf_tools_path() / "ccache"), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + # Don't override CCACHE_* values the user already set in their environment. + return {k: v for k, v in defaults.items() if k not in os.environ} + + def get_framework_env( framework_path: PathType, python_env_path: PathType | None = None, @@ -835,7 +894,7 @@ def get_framework_env( """ # 1. Initialize base environment with extra ESP-IDF environment variables env = env.copy() if env else {} - env["IDF_TOOLS_PATH"] = str(_get_idf_tools_path()) + env["IDF_TOOLS_PATH"] = str(get_idf_tools_path()) env["IDF_PATH"] = "" # 2. Get existing PATH from env or os.environ @@ -859,4 +918,7 @@ def get_framework_env( env.update(export_vars) env["PATH"] = os.pathsep.join(paths_to_export + path_list) + # 6. Enable ccache for the compile toolchain (default on when available). + env.update(_ccache_env()) + return env diff --git a/esphome/espota2.py b/esphome/espota2.py index 266702c142..fa15c1dda2 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -52,6 +52,7 @@ RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90 RESPONSE_ERROR_BOOTLOADER_VERIFY = 0x91 RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92 +RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93 RESPONSE_ERROR_UNKNOWN = 0xFF OTA_VERSION_1_0 = 1 @@ -157,6 +158,11 @@ _ERROR_MESSAGES: dict[int, str] = { "the bootloader update without rebooting the device. If the device " "fails to boot, recover it via a serial flash." ), + RESPONSE_ERROR_VERSION_DOWNGRADE: ( + "The device rejected the update because it has OTA downgrade protection " + "enabled: the new firmware's version must be newer than the version the " + "device is currently running." + ), RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP", } diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 276dfbbf1c..70d440d995 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -11,8 +11,6 @@ import sys import time from typing import IO -import requests - from esphome.helpers import ProgressBar, rmtree PathType = str | os.PathLike @@ -20,6 +18,32 @@ 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 get_project_cxx_compile_flags() -> list[str]: + """Return the sorted flags that apply to C++ compiles only.""" + from esphome.core import CORE # local import to avoid circular dependency + + return sorted(CORE.cxx_build_flags) + + def str_to_lst_of_str(a: str | list[str]) -> list[str]: """ Convert a string to a list of string @@ -222,22 +246,19 @@ def _tar_extract_all( """ Extract a TAR archive to the specified directory. - Implementation is inspired by Python 3.12's tarfile data filtering logic. - This can be replaced with the standard library implementation once - support for Python 3.11 is no longer required. + Path-traversal, link, permission and ownership sanitization is delegated to + the stdlib ``tarfile.data_filter`` (PEP 706). We keep the wrapper-directory + stripping (no stdlib equivalent) and the absolute-path reject (data_filter's + check is os.path-dependent and would miss a Windows drive path when + extracting on POSIX). Args: data: File-like object containing the TAR archive extract_dir: Directory to extract contents to progress_header: If set, show a progress bar with this header """ - import stat import tarfile - # Tar extraction safety: os.path.realpath / commonpath / normpath have no - # pathlib equivalents and Path.resolve() would follow symlinks unsafely. - # Use os.path for the security-sensitive parts; the simple checks move to - # Path. extract_dir = os.fspath(extract_dir) abs_dest = os.path.abspath(extract_dir) # noqa: PTH100 @@ -252,18 +273,14 @@ def _tar_extract_all( safe_members = [] for member in all_members: - name = member.name - - # 1. Strip leading slashes - name = name.lstrip("/" + os.sep) - - # 2. Reject absolute paths (incl. Windows drive) + # Strip leading slashes, then reject absolute / Windows-drive paths + name = member.name.lstrip("/" + os.sep) if Path(name).is_absolute() or ( os.name == "nt" and ":" in name.split(os.sep)[0] # noqa: PTH206 ): continue - # 3. Strip wrapper directory if one was detected + # Strip wrapper directory if one was detected if strip_prefix is not None: norm = name.replace("\\", "/") if norm in (strip_root, strip_prefix): @@ -271,88 +288,29 @@ def _tar_extract_all( if not norm.startswith(strip_prefix): continue name = norm[len(strip_prefix) :] - - # 4. Compute final path - target_path = os.path.realpath(os.path.join(abs_dest, name)) # noqa: PTH118 - if os.path.commonpath([abs_dest, target_path]) != abs_dest: - continue - - # 5. Validate links properly - if member.issym() or member.islnk(): - linkname = member.linkname - - # Reject absolute link targets - if Path(linkname).is_absolute(): - continue - - if member.islnk() and strip_prefix is not None: - # Hard-link linknames reference another archive member - # by its archive name. We've stripped the wrapper prefix - # from member.name above (step 3); strip it here too so - # tarfile._find_link_target can resolve the target during - # extraction. Symlink linknames are filesystem-relative - # paths, not archive-member references, so they don't - # need this treatment. - norm_link = linkname.replace("\\", "/") - if norm_link in (strip_root, strip_prefix): - continue - if not norm_link.startswith(strip_prefix): - continue - linkname = norm_link[len(strip_prefix) :] - - # Strip leading slashes - linkname = os.path.normpath(linkname) - - if member.issym(): - link_target = os.path.join( # noqa: PTH118 - abs_dest, - os.path.dirname(name), # noqa: PTH120 - linkname, - ) - else: - link_target = os.path.join(abs_dest, linkname) # noqa: PTH118 - link_target = os.path.realpath(link_target) - - if os.path.commonpath([abs_dest, link_target]) != abs_dest: - continue - - # write back normalized linkname - member.linkname = linkname - - # 6. Sanitize permissions - mode = member.mode - if mode is not None: - # Strip high bits & group/other write bits - mode &= ( - stat.S_IRWXU - | stat.S_IRGRP - | stat.S_IXGRP - | stat.S_IROTH - | stat.S_IXOTH - ) - if member.isfile() or member.islnk(): - # remove exec bits unless explicitly user-executable - if not (mode & stat.S_IXUSR): - mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - mode |= stat.S_IRUSR | stat.S_IWUSR - elif not (member.isdir() or member.issym()): - # Block special files. Directories and symlinks keep - # their masked-original mode — passing None here would - # crash tarfile.extract on Python <3.12 (its chmod - # path calls os.chmod unconditionally). - continue - - member.mode = mode - - # 7. Strip ownership - member.uid = None - member.gid = None - member.uname = None - member.gname = None - - # 8. Assign sanitized name back member.name = name + # Hard-link linknames reference another archive member by its + # archive name; strip the wrapper prefix here too so + # tarfile._find_link_target can resolve the target during + # extraction. Symlink linknames are filesystem-relative paths, + # not archive-member references, so they don't need this. + if member.islnk() and strip_prefix is not None: + norm_link = member.linkname.replace("\\", "/") + if norm_link in (strip_root, strip_prefix): + continue + if not norm_link.startswith(strip_prefix): + continue + member.linkname = norm_link[len(strip_prefix) :] + + # Delegate traversal, link, permission and ownership sanitization + # to the stdlib data filter; it raises FilterError for unsafe + # members (path traversal, links outside dest, special files). + try: + member = tarfile.data_filter(member, abs_dest) + except tarfile.FilterError: + continue + safe_members.append(member) total = len(safe_members) @@ -616,6 +574,10 @@ def download_from_mirrors( ValueError: If mirrors list is empty. Exception: If all download attempts fail. """ + # Imported lazily: requests is a heavy import (~85ms) and is only needed + # when actually downloading a toolchain, never during config validation. + import requests + # 1. Open target file for writing if path given with ExitStack() as stack: if isinstance(target, (str, os.PathLike)): diff --git a/esphome/helpers.py b/esphome/helpers.py index ef7e2d0b93..631bcb6f39 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -124,14 +124,8 @@ def slugify(value: str) -> str: def friendly_name_slugify(value: str) -> str: """Convert a friendly name to a slug with dashes instead of underscores. - Used by: - - esphome.dashboard.web_server (legacy dashboard) - - device-builder (esphome/device-builder) — slugifies friendly names - into the YAML filename / device name during adoption + wizard flows. - - Lives here rather than in ``esphome.dashboard.util.text`` so it - survives the legacy dashboard's eventual removal. - The dashboard module re-exports this name as a back-compat shim. + Used by device-builder (esphome/device-builder), which slugifies friendly + names into the YAML filename / device name during adoption + wizard flows. Coordinate with the device-builder team before changing the slugification rules — the mapping must stay stable so existing on-disk filenames keep matching across releases. @@ -403,17 +397,13 @@ def rmtree(path: Path | str) -> None: read-only flag and retrying. """ - def _onerror(func, path, exc_info): + def _onexc(func, path, exc): if os.access(path, os.W_OK): - raise exc_info[1].with_traceback(exc_info[2]) + raise exc Path(path).chmod(stat.S_IWUSR | stat.S_IRUSR) func(path) - # ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different - # callable signature); keep the existing handler shape for now and - # silence the lint locally so this PR doesn't bundle an unrelated - # migration. - shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument + shutil.rmtree(path, onexc=_onexc) def walk_files(path: Path): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 5f3000e52d..60b00d33c7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -1,6 +1,6 @@ dependencies: bblanchon/arduinojson: - version: "7.4.2" + version: "7.4.3" esphome/dlms_parser: version: 1.1.0 esphome/esp-audio-libs: @@ -12,7 +12,7 @@ dependencies: esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: - version: 0.2.3 + version: 0.4.0 esphome/micro-opus: version: 0.4.1 esphome/micro-wav: @@ -47,12 +47,8 @@ dependencies: version: "2.0.0" rules: - if: "target in [esp32, esp32p4]" - espressif/esp-zboss-lib: - version: 1.6.4 - rules: - - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/esp-zigbee-lib: - version: 1.6.8 + version: 2.0.3 rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: @@ -84,9 +80,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/esp_tinyusb: - version: "2.1.1" + version: "2.2.1" rules: - - if: "target in [esp32s2, esp32s3, esp32p4]" + - if: "target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esphome/esp-hub75: version: 0.3.5 rules: @@ -96,9 +92,9 @@ dependencies: rules: - if: "idf_version >=6.0.0" espressif/usb: - version: "1.3.0" + version: "1.4.1" rules: - - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]" + - if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]" esp32async/asynctcp: version: 3.4.91 sendspin/sendspin-cpp: diff --git a/esphome/loader.py b/esphome/loader.py index 8823d82fc1..22db8b156a 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) @@ -114,6 +135,19 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: + """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. + + Called once, before platform entries are processed, with the raw top-level + config for this domain. It may transform a pre-platform-format config (e.g. + a bare list or legacy dict form) into the normalized list of `platform:` + tagged entries and return it. Returning ``None`` means "already in the new + format, leave untouched". This is an intentionally removable deprecation + shim hook. + """ + return getattr(self.module, "LEGACY_CONFIG_MIGRATE", None) + @property def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. @@ -216,6 +250,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 +306,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/library.py b/esphome/platformio/library.py new file mode 100644 index 0000000000..291bedb5cd --- /dev/null +++ b/esphome/platformio/library.py @@ -0,0 +1,735 @@ +"""Toolchain-agnostic PlatformIO library converter. + +Resolves a batch of PlatformIO/Arduino library specs (added via +``cg.add_library(...)``) into local, build-ready directories: it fetches each +library (registry/git/url), parses its ``library.json`` / ``library.properties`` +manifest, resolves the whole dependency graph to a single version per name, and +caches the result under ``/pio_components``. + +The toolchain-specific part — turning a resolved library into build files +(ESP-IDF ``idf_component_register`` CMakeLists, or a Zephyr module) — is supplied +by a :class:`LibraryBackend`. This module owns everything that is the same +regardless of which toolchain consumes the result. +""" + +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field +import glob +import hashlib +import itertools +import json +import logging +import os +from pathlib import Path +import re +import tempfile +from typing import Any +from urllib.parse import urlparse, urlsplit, urlunsplit + +from esphome import git +from esphome.core import CORE, Library +from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir + +_LOGGER = logging.getLogger(__name__) + +PathType = str | os.PathLike + +# +# Constants from platformio +# + +FILTER_REGEX = re.compile(r"([+-])<([^>]+)>") +DEFAULT_BUILD_SRC_FILTER = ( + "+<*> -<.git/> -<.svn/> - - - -" +) +DEFAULT_BUILD_SRC_DIRS = "src" +DEFAULT_BUILD_INCLUDE_DIR = "include" +DEFAULT_BUILD_FLAGS = [] +SRC_FILE_EXTENSIONS = [ + ".c", + ".cpp", + ".cc", + ".cxx", + ".c++", + ".S", + ".spp", + ".SPP", + ".sx", + ".s", + ".asm", + ".ASM", +] + +DOMAIN = "pio_components" + +ESPHOME_DATA_KEY = "ESPHOME" +ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE" + + +class Source: + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + raise NotImplementedError + + +class URLSource(Source): + def __init__(self, url: str): + self.url = url + + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + # Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so + # the build files each backend writes into the library dir can't collide. + base_dir = Path(CORE.data_dir) / DOMAIN + if namespace: + base_dir = base_dir / namespace + 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 + # extraction is correctly detected and re-run on the next invocation, + # and lets us extract directly into ``path`` — avoiding a + # post-extraction rename that races with antivirus on Windows. + extracted_marker = path / ".esphome_extracted" + if not extracted_marker.is_file() or force: + rmdir(path, msg=f"Clean up library directory {path}") + + # Download in temporary file + with tempfile.NamedTemporaryFile() as tmp: + _LOGGER.info("Downloading %s ...", self.url) + _LOGGER.debug("Location: %s", path) + + download_from_mirrors([self.url], {}, tmp.file) + + _LOGGER.debug("Extracting archive to %s ...", path) + archive_extract_all(tmp.file, path) + extracted_marker.touch() + return path + + def __str__(self): + return self.url + + +class GitSource(Source): + def __init__(self, url: str, ref: str | None): + self.url = url + self.ref = ref + + def download( + self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = "" + ) -> Path: + domain = DOMAIN + if namespace: + domain = f"{domain}/{namespace}" + if salt: + domain = f"{domain}/{salt}" + path, _ = git.clone_or_update( + url=self.url, + ref=self.ref, + refresh=git.NEVER_REFRESH if not force else None, + domain=domain, + submodules=[], + subpath=Path(dir_suffix), + ) + return path + + def __str__(self): + return f"{self.url}#{self.ref}" if self.ref else self.url + + +class InvalidLibrary(Exception): + pass + + +class ConvertedLibrary: + """A resolved PlatformIO library plus its parsed manifest and on-disk path. + + Toolchain-neutral: ESP-IDF treats it as a component, Zephyr as a module. The + backend reads ``name``/``version``/``data``/``dependencies``/``path`` to emit + its build files. + """ + + def __init__(self, name: str, version: str, source: Source | None): + self.name = name + self.version = version + self.source = source + self.data = {} + self.dependencies: list[ConvertedLibrary] = [] + self._path: Path | None = None + + def __str__(self): + return f"{self.name}@{self.version}={self.source}" + + @property + def path(self) -> Path: + if self._path is None: + raise RuntimeError(f"path not set for library {self}") + return self._path + + @path.setter + def path(self, value: Path) -> None: + self._path = value + + def get_sanitized_name(self): + return re.sub(r"[^a-zA-Z0-9_.\-/]", "_", self.name) + + def get_require_name(self): + return self.get_sanitized_name().replace("/", "__") + + def download(self, force: bool = False, salt: str = "", namespace: str = ""): + """Fetch the library into the shared cache and record its ``path``. + + The cache directory is named after the sanitized library name; backends + rely on that name to identify the unit they build (e.g. ESP-IDF uses the + directory name as the component name, replacing ``/`` with ``__`` via + ``get_require_name``). ``namespace`` keeps each backend's cache separate. + """ + self.path = self.source.download( + self.get_sanitized_name(), force=force, salt=salt, namespace=namespace + ) + + +@dataclass +class LibraryBackend: + """Toolchain hooks for :func:`convert_libraries`. + + ``platform``/``framework`` drive the manifest compatibility check. + ``emit`` writes the toolchain-specific build files into a resolved library's + ``path`` (e.g. the ESP-IDF ``CMakeLists.txt`` + ``idf_component.yml``, or a + Zephyr ``module.yml`` + ``CMakeLists.txt``). + ``cache_key`` namespaces the download cache (``pio_components//``) + so the differing build files two backends emit into a library dir never + collide when the same config dir hosts both an ESP-IDF and a Zephyr build. + """ + + platform: str | None + framework: str + emit: Callable[["ConvertedLibrary"], None] + cache_key: str + + +def ensure_list[T](obj: T | list[T]) -> list[T]: + """ + Convert an object to a list if it isn't already a list. + + Args: + obj: Object that may or may not already be a list. + + Returns: + list[T]: The original list if ``obj`` is a list, otherwise a single-item + list containing ``obj``. + """ + return [obj] if not isinstance(obj, list) else obj + + +def _owner_pkgname_to_name(owner: str | None, pkgname: str) -> str: + """ + Convert owner and package name to a standardized component name. + + This function combines owner and package name with a forward slash when + both are provided, otherwise returns just the package name. + + Args: + owner: The owner/username of the package (can be None) + pkgname: The name of the package + + Returns: + str: The standardized component name in "owner/pkgname" format or just "pkgname" + """ + return f"{owner}/{pkgname}" if owner else pkgname + + +def collect_filtered_files(src_dir: PathType, src_filters: list[str]) -> list[str]: + """ + Recursively match files in a directory according to include/exclude patterns. + + This function processes a list of filter strings that indicate which files + to include or exclude. Each filter is parsed into patterns with a sign: + '+' for inclusion and '-' for exclusion. Directory patterns ending with '/' + are normalized to include all their contents recursively. + + Args: + src_dir (PathType): Root directory to search within. + src_filters (list[str]): List of filter strings, which may contain multiple + patterns. Each pattern can start with '+' or '-' to indicate inclusion + or exclusion. + + Returns: + list[str]: List of matched file paths as strings. Only files (not directories) + are returned, even if a directory matches a pattern. + """ + matches = list( + itertools.chain.from_iterable( + FILTER_REGEX.findall(src_filter) for src_filter in src_filters + ) + ) + + selected = set() + + for sign, pattern in matches: + pattern = pattern.strip() + + if pattern.endswith("/"): + pattern = pattern.rstrip("/") + "/**" + + # glob.escape has no pathlib equivalent and the matcher works on raw + # path strings, so PTH118/PTH207 don't apply here. + full_pattern = os.path.join(glob.escape(str(src_dir)), pattern) # noqa: PTH118 + + matched = [] + for item in glob.glob(full_pattern, recursive=True): # noqa: PTH207 + if not Path(item).is_dir(): + matched.append(item) + else: + # PlatformIO quirk: a directory matched with "*" should include all its + # nested files and subdirectories, not just the directory itself. + for root, _, files in os.walk(item): + matched.extend([str(Path(root) / f) for f in files]) + + # FILTER_REGEX only ever captures "+" or "-", so the else is the "-" case. + if sign == "+": + selected.update(matched) + else: + selected.difference_update(matched) + + return [r for r in selected if Path(r).is_file()] + + +def split_list_by_condition( + items: list[str], match_fn: Callable[[str], str | None] +) -> tuple[list[str], list[str]]: + """ + Splits a list into two lists based on a matching function. + + Args: + items: List of items to split. + match_fn: Function that returns a value for items that should go into the "matched" list. + + Returns: + A tuple (matched, non_matched) + """ + matched = [] + non_matched = [] + for item in items: + result = match_fn(item) + if result: + matched.append(result) + else: + non_matched.append(item) + return matched, non_matched + + +def check_library_data(data: dict, platform: str | None, framework: str): + """ + Check whether a library manifest is compatible with the target toolchain. + + A platform mismatch (e.g. an AVR-only library on ESP32) raises + ``InvalidLibrary`` so the caller skips the library. A framework mismatch only + logs a warning — PIO manifests often understate the frameworks they actually + compile under, and there's no opt-out at this layer, so we include the library + anyway. + + Args: + data: PIO library manifest dict being processed. + platform: The PlatformIO platform token the build targets (e.g. + ``espressif32``). ``None`` skips the platform check entirely — useful + for targets (e.g. Zephyr) where PIO manifests rarely declare the + platform yet portable libraries still build. + framework: The active framework name (e.g. ``espidf``, ``arduino``, + ``zephyr``) the manifest is expected to declare. + + Raises: + InvalidLibrary: If the library does not support the target platform. + """ + platforms = data.get("platforms", "*") + if isinstance(platforms, str): + platforms = [a.strip() for a in platforms.split(",")] + platforms = ensure_list(platforms) + + # Check if library supports the target platform + valid_platforms = platform is None or "*" in platforms or platform in platforms + + if not valid_platforms: + raise InvalidLibrary(f"Unsupported library platforms: {platforms}") + + frameworks = data.get("frameworks", "*") + if isinstance(frameworks, str): + frameworks = [a.strip() for a in frameworks.split(",")] + frameworks = ensure_list(frameworks) + + # Check if library declares the active framework. PIO library manifests + # often list only "arduino" even when the library actually compiles fine + # under the target framework, and there's no way to opt out of the check at + # this layer. Warn instead of failing so the user isn't forced to fork the + # library to fix the manifest. + valid_framework = "*" in frameworks or framework in frameworks + + if not valid_framework: + _LOGGER.warning( + "Library %s declares frameworks %s that do not include '%s'; including anyway", + data.get("name", ""), + frameworks, + framework, + ) + + +def _parse_library_json(library_json_path: PathType): + """ + Load and parse a JSON file describing a library. + + Args: + library_json_path (PathType): Path to the JSON file. + + Returns: + dict: Parsed JSON content as a Python dictionary. + """ + with Path(library_json_path).open(encoding="utf8") as fp: + return json.load(fp) + + +def _parse_library_properties(library_properties_path: PathType): + """ + Parse a key-value platformio .properties style file into a dictionary. + + Args: + library_properties_path (PathType): Path to the properties file. + + Returns: + dict[str, str]: Mapping of parsed property keys to values. + """ + with Path(library_properties_path).open(encoding="utf8") as fp: + data = {} + for line in fp.read().splitlines(): + line = line.strip() + if not line or "=" not in line: + continue + # skip comments + if line.startswith("#"): + continue + key, value = line.split("=", 1) + if not value.strip(): + continue + data[key.strip()] = value.strip() + return data + + +def _make_registry_client() -> Any: + """Create a minimal PlatformIO registry client with no system filtering. + + ``is_system_compatible`` is forced True so version selection is driven purely + by the requested version requirements -- target compatibility is handled + elsewhere, not by the PlatformIO registry. + """ + from platformio.package.manager._registry import PackageManagerRegistryMixin + + class _Registry(PackageManagerRegistryMixin): + def __init__(self) -> None: + self._registry_client = None + self.pkg_type = "library" + + @staticmethod + def is_system_compatible(value: Any, custom_system: Any = None) -> bool: + return True + + return _Registry() + + +def _resolve_registry_version( + owner: str | None, pkgname: str, requirements: set[str] +) -> tuple[str, str, str, str]: + """Resolve a registry package to the single highest version satisfying ALL + the given requirements; return ``(owner, name, version, download_url)``. + + Intersecting every requirement (rather than resolving each consumer in + isolation) makes the result independent of processing order and guarantees + no stated constraint is violated -- e.g. ``esphome/libsodium`` requested as + both ``==1.10021.0`` and ``^1.10018.1`` resolves to ``1.10021.0``. + """ + from platformio.package.meta import PackageSpec + + registry = _make_registry_client() + package = registry.fetch_registry_package(PackageSpec(owner=owner, name=pkgname)) + owner = package["owner"]["username"] + name = package["name"] + + # Chaining the per-requirement filter intersects all constraints. + versions = package.get("versions") or [] + for requirement in sorted(requirements): + versions = registry.get_compatible_registry_versions( + versions, PackageSpec(owner=owner, name=name, requirements=requirement) + ) + if not versions: + raise RuntimeError( + f"No version of {owner}/{name} satisfies all requirements " + f"{sorted(requirements)} requested across the library tree" + ) + + best = registry.pick_best_registry_version(versions) + pkgfile = registry.pick_compatible_pkg_file(best["files"]) + if not pkgfile: + raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}") + return owner, name, best["name"], pkgfile["download_url"] + + +def _normalize_dependencies(dependencies: Any) -> list[dict]: + """Normalize a library manifest's ``dependencies`` to a list of dicts. + + PIO's library.json accepts both the list-of-dicts form and the shorthand + dict form (``{"owner/Name": "version_spec"}``); normalize the latter so + callers see a uniform list. + """ + if not dependencies: + return [] + if isinstance(dependencies, dict): + normalized = [] + for raw_name, spec in dependencies.items(): + if "/" in raw_name: + owner, pkgname = raw_name.split("/", 1) + else: + owner, pkgname = None, raw_name + entry = {"name": pkgname, "owner": owner} + if isinstance(spec, dict): + entry.update(spec) + else: + entry["version"] = spec + normalized.append(entry) + return normalized + return [d for d in dependencies if isinstance(d, dict)] + + +@dataclass +class _LibNode: + """A node in the library dependency graph being resolved as a batch.""" + + key: str + is_git: bool + owner: str | None = None + pkgname: str | None = None + requirements: set[str] = field(default_factory=set) + url: str | None = None + ref: str | None = None + edges: set[str] = field(default_factory=set) + + +def _node_key( + name: str | None, version: str | None, repository: str | None +) -> tuple[str, bool, tuple[str | None, str | None]]: + """Return ``(key, is_git, locator)`` for a library or dependency spec. + + The key is derived from the *input* spec (the registry name as written, or + the git URL path), not the resolved canonical name. So a package referenced + inconsistently -- bare ``name`` vs ``owner/name``, or git vs registry -- maps + to distinct keys and isn't deduplicated; ``convert_libraries`` warns about + that after resolution rather than merging the nodes. + """ + if repository: + split_result = urlsplit(repository) + key = str(split_result.path).strip("/").removesuffix(".git") + ref = split_result.fragment.strip() or None + url = urlunsplit(split_result._replace(fragment="")) + return key, True, (url, ref) + if name and "/" in name: + owner, pkgname = name.split("/", 1) + else: + owner, pkgname = None, name + return name, False, (owner, pkgname) + + +def convert_libraries( + libraries: list[Library], backend: LibraryBackend +) -> list[ConvertedLibrary]: + """Resolve and convert a batch of PlatformIO libraries for ``backend``. + + Resolves the whole set together rather than each library independently: it + walks the dependency graph collecting every version *requirement* per + component name, then resolves each name once to a single version satisfying + all of them. So a transitive dependency shared under + different specs (e.g. ``esphome/libsodium``, pulled by both ``noise-c`` and + ``esp_wireguard``) becomes one component instead of two clashing + ``override_path`` entries -- order-independently, and without ever violating + a stated constraint. + + The returned list holds the top-level components (those directly requested); + transitive dependencies are converted too and wired into each component's + generated manifest. ``backend.emit`` is called once per converted library to + write its toolchain-specific build files. + + ``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 build files 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) + nodes[key] = node + if is_git: + node.is_git = True + node.url, node.ref = locator + else: + node.owner, node.pkgname = locator + if version: + node.requirements.add(version) + return key + + 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 + # requirement set has grown since the last time, so every requirement in the + # graph is accounted for before conversion. + components: dict[str, ConvertedLibrary] = {} + resolved_requirements: dict[str, frozenset[str]] = {} + top_level_keys = set(top_level) + worklist = deque(dict.fromkeys(top_level)) + while worklist: + key = worklist.popleft() + node = nodes[key] + + # A node is queued once per referring edge; skip the (uncached) registry + # lookup + download + dependency walk unless its requirement set grew + # since the last resolve. Requirements only ever grow, so this still + # converges the fixpoint and terminates dependency cycles. + requirements = frozenset(node.requirements) + if resolved_requirements.get(key) == requirements: + continue + resolved_requirements[key] = requirements + + if node.is_git: + component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref)) + else: + owner, name, version, url = _resolve_registry_version( + node.owner, node.pkgname, node.requirements + ) + component = ConvertedLibrary( + _owner_pkgname_to_name(owner, name), version, URLSource(url) + ) + component.download(salt=salt, namespace=backend.cache_key) + + library_json_path = component.path / "library.json" + library_properties_path = component.path / "library.properties" + if library_json_path.is_file(): + component.data = _parse_library_json(library_json_path) + elif library_properties_path.is_file(): + component.data = _parse_library_properties(library_properties_path) + else: + raise RuntimeError( + f"Invalid PIO library {key}: missing library.json and " + "library.properties" + ) + + try: + check_library_data(component.data, backend.platform, backend.framework) + except InvalidLibrary as e: + # Skip an incompatible transitive dependency, but fail fast if a + # top-level library the build explicitly requested is incompatible. + if key in top_level_keys: + raise RuntimeError( + f"Requested library {key} is not compatible with " + f"{backend.framework}: {e}" + ) from e + _LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e)) + continue + components[key] = component + + # Requirements changed (we got past the short-circuit above), so + # (re)walk this component's dependencies. + node.edges = set() + for dependency in _normalize_dependencies(component.data.get("dependencies")): + if "name" not in dependency or "version" not in dependency: + continue + try: + check_library_data(dependency, backend.platform, backend.framework) + except InvalidLibrary 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 + try: + parsed = urlparse(dep_version) + if all([parsed.scheme, parsed.netloc]): + dep_url, dep_version = dep_version, None + except (TypeError, ValueError): + pass + dep_key = add_spec(dep_name, dep_version, dep_url) + node.edges.add(dep_key) + worklist.append(dep_key) + + # A git source wins over any registry version requested for the same + # component. That's intentional, but warn so a dropped registry pin isn't a + # silent surprise. + for node in nodes.values(): + if node.is_git and node.requirements: + _LOGGER.warning( + "Library %s is requested both from a git source (%s) and as " + "registry version(s) %s; using the git source.", + node.key, + node.url, + sorted(node.requirements), + ) + + # Two graph nodes that resolve to the same component name (e.g. a package + # referenced both bare and as ``owner/name``) are not deduplicated and can + # produce conflicting component definitions. Warn so it's not silent. + canonical_keys: dict[str, str] = {} + for node_key, component in components.items(): + canonical = component.get_sanitized_name() + if canonical_keys.setdefault(canonical, node_key) != node_key: + _LOGGER.warning( + "Library %s is referenced under multiple names (%s and %s); these " + "are not deduplicated. Reference it consistently as %s.", + canonical, + canonical_keys[canonical], + node_key, + canonical, + ) + + # Wire each component's dependencies to the single resolved instances, then + # emit build files. + for key, component in components.items(): + component.dependencies = [ + components[dep_key] + for dep_key in sorted(nodes[key].edges) + if dep_key in components + ] + for component in components.values(): + backend.emit(component) + + return [components[key] for key in top_level if key in components] diff --git a/esphome/preferences.py b/esphome/preferences.py new file mode 100644 index 0000000000..fce8519130 --- /dev/null +++ b/esphome/preferences.py @@ -0,0 +1,106 @@ +"""Helpers for letting a component choose where a preference is persisted. + +Preferences can be stored either in flash (durable across power loss) or in RTC +memory (fast, survives deep sleep and soft resets but not power loss). The +flash-vs-RTC choice is only meaningful on platforms whose preferences backend +honors the ``in_flash`` flag — currently ESP32 and ESP8266. On other platforms +the value is accepted only as ``flash`` (the sole supported backend). + +Components include :func:`storage_schema` in their config and convert the chosen +value with :func:`is_in_flash` when calling ``make_preference``. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_STORAGE +from esphome.core import CORE + +STORAGE_FLASH = "flash" +STORAGE_RTC = "rtc" + + +def _rtc_supported() -> bool: + """Whether the active platform has an RTC-backed preferences backend. + + Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 + and -C61 have no RTC memory at all, so RTC storage is unavailable there. + """ + if CORE.is_esp8266: + return True + if CORE.is_esp32: + from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + + return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) + return False + + +def _default_storage() -> str: + """Default that preserves each platform's historic behavior. + + ESP8266 has always stored these preferences in RTC memory; every other + platform effectively used flash. Evaluated at validation time. + """ + return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH + + +def _validate_storage(value): + value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) + if value == STORAGE_RTC and not _rtc_supported(): + raise cv.Invalid( + f"'{STORAGE_RTC}' storage is not supported on this platform; only " + f"'{STORAGE_FLASH}' is available" + ) + return value + + +def storage_schema(): + """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" + return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + + +def request_rtc_storage() -> None: + """Compile the RTC-backed storage into the ESP32 preferences backend. + + The RTC storage region is left out of ESP32 builds unless something asks for + it, so unused builds don't reserve RTC memory. Call this from ``to_code`` + when a config option selects RTC storage. No-op on other platforms (ESP8266 + always has its RTC backend). + """ + if CORE.is_esp32: + cg.add_define("USE_ESP32_RTC_PREFERENCES") + + +def validate_rtc_storage(value): + """Validate a boolean option that requests RTC-backed preference storage. + + ``false`` means "no request", not "disable": it never turns RTC storage off + (another option selecting ``storage: rtc`` still compiles it in). On ESP8266 + the backend is integral and always enabled, so ``false`` is rejected rather + than silently ignored; ``true`` is a tolerated no-op there so shared config + packages work across mixed fleets. + """ + value = cv.boolean(value) + if not value: + if CORE.is_esp8266: + raise cv.Invalid( + "RTC preference storage is always enabled on ESP8266 and cannot " + "be disabled" + ) + return value + if not _rtc_supported(): + raise cv.Invalid("RTC preference storage is not supported on this platform") + return value + + +def is_in_flash(value: str) -> bool: + """Map a CONF_STORAGE value to the ``in_flash`` argument of make_preference. + + Call this from ``to_code``: when RTC storage is selected on ESP32 it also emits + the define that compiles the RTC storage buffer into the ESP32 backend (see + :func:`request_rtc_storage`). + """ + in_flash = value == STORAGE_FLASH + if not in_flash: + request_rtc_storage() + return in_flash diff --git a/esphome/storage_json.py b/esphome/storage_json.py index 3bdda1a9a1..6376e573c4 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_DISABLED, CONF_MDNS, KEY_CORE, + KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, Toolchain, @@ -71,14 +72,10 @@ def _to_path_if_not_none(value: str | None) -> Path | None: class StorageJSON: """Persisted device metadata sidecar. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — reads/writes the same - JSON file as the legacy dashboard so a single config_dir can be - shared between the two during the transition. The schema - (``storage_version``, field names, types) must stay backwards - compatible — coordinate with the device-builder team before - adding required fields or changing semantics of existing ones. + Used by device-builder (esphome/device-builder), which reads/writes this + JSON file. The schema (``storage_version``, field names, types) must stay + backwards compatible — coordinate with the device-builder team before + adding required fields or changing semantics of existing ones. """ def __init__( @@ -136,7 +133,7 @@ class StorageJSON: self.no_mdns = no_mdns # The framework used to compile the firmware self.framework = framework - # The core platform of this firmware. Like "esp32", "rp2040", "host" etc. + # The core platform of this firmware. Like "esp32", "rp2", "host" etc. self.core_platform = core_platform # The toolchain used for the build ("platformio" / "esp-idf") self.toolchain = toolchain @@ -183,6 +180,8 @@ class StorageJSON: hardware = esp32.get_esp32_variant(esph) framework_version = str(esp32.idf_version()) + elif esph.is_nrf52: + framework_version = str(esph.data[KEY_CORE][KEY_FRAMEWORK_VERSION]) return StorageJSON( storage_version=1, name=esph.name, @@ -338,6 +337,19 @@ class StorageJSON: f"Please clean the build files and recompile." ) from err CORE.data[KEY_ESP32] = esp32_data + elif target_platform == const.PLATFORM_NRF52 and self.framework_version: + import esphome.config_validation as cv + + try: + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version.parse( + self.framework_version + ) + except ValueError as err: + raise EsphomeError( + f"Could not parse the framework version " + f"{self.framework_version!r} from {storage_path()}. " + f"Please clean the build files and recompile." + ) from err def __eq__(self, o) -> bool: return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict() diff --git a/esphome/wizard.py b/esphome/wizard.py index f83342cc6a..f7706928e9 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -75,8 +75,8 @@ esp32: type: esp-idf """ -RP2040_CONFIG = """ -rp2040: +RP2_CONFIG = """ +rp2: board: {board} """ @@ -98,7 +98,7 @@ rtl87xx: HARDWARE_BASE_CONFIGS = { "ESP8266": ESP8266_CONFIG, "ESP32": ESP32_CONFIG, - "RP2040": RP2040_CONFIG, + "RP2": RP2_CONFIG, "BK72XX": BK72XX_CONFIG, "LN882X": LN882X_CONFIG, "RTL87XX": RTL87XX_CONFIG, @@ -113,7 +113,7 @@ class WizardFileKwargs(TypedDict): """Keyword arguments for wizard_file function.""" name: str - platform: Literal["ESP8266", "ESP32", "RP2040", "BK72XX", "LN882X", "RTL87XX"] + platform: Literal["ESP8266", "ESP32", "RP2", "BK72XX", "LN882X", "RTL87XX"] board: str ssid: NotRequired[str] psk: NotRequired[str] @@ -213,7 +213,7 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards name = kwargs["name"] @@ -235,8 +235,8 @@ def wizard_write(path: Path, **kwargs: Unpack[WizardWriteKwargs]) -> bool: platform = "ESP8266" elif board in esp32_boards.BOARDS: platform = "ESP32" - elif board in rp2040_boards.BOARDS: - platform = "RP2040" + elif board in rp2_boards.BOARDS: + platform = "RP2" elif board in bk72xx_boards.BOARDS: platform = "BK72XX" elif board in ln882x_boards.BOARDS: @@ -301,7 +301,7 @@ def wizard(path: Path) -> int: from esphome.components.esp32 import boards as esp32_boards from esphome.components.esp8266 import boards as esp8266_boards from esphome.components.ln882x import boards as ln882x_boards - from esphome.components.rp2040 import boards as rp2040_boards + from esphome.components.rp2 import boards as rp2_boards from esphome.components.rtl87xx import boards as rtl87xx_boards if path.suffix not in (".yaml", ".yml"): @@ -373,7 +373,7 @@ def wizard(path: Path) -> int: "firmwares for it." ) - wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2040"] + wizard_platforms = ["ESP32", "ESP8266", "BK72XX", "LN882X", "RTL87XX", "RP2"] safe_print( "Please choose one of the supported microcontrollers " "(Use ESP8266 for Sonoff devices)." @@ -405,7 +405,7 @@ def wizard(path: Path) -> int: board_link = ( "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) - elif platform == "RP2040": + elif platform == "RP2": board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" @@ -421,27 +421,21 @@ def wizard(path: Path) -> int: safe_print(f"(Type {color(AnsiFore.GREEN, 'esp01_1m')} for Sonoff devices)") safe_print() # Don't sleep because user needs to copy link - if platform == "ESP32": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcu-32s")}".') - boards_list = esp32_boards.BOARDS.items() - elif platform == "ESP8266": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "nodemcuv2")}".') - boards_list = esp8266_boards.BOARDS.items() - elif platform == "BK72XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "cb2s")}".') - boards_list = bk72xx_boards.BOARDS.items() - elif platform == "LN882X": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wl2s")}".') - boards_list = ln882x_boards.BOARDS.items() - elif platform == "RTL87XX": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "wr3")}".') - boards_list = rtl87xx_boards.BOARDS.items() - elif platform == "RP2040": - safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, "rpipicow")}".') - boards_list = rp2040_boards.BOARDS.items() - - else: - raise NotImplementedError("Unknown platform!") + # Platform-to-(example board, boards module) lookup. Dict-driven so the + # set of supported platforms has a single source of truth and the elif + # chain — which left the last entry's "False" branch structurally + # unreachable in tests — is gone. + example_boards = { + "ESP32": ("nodemcu-32s", esp32_boards), + "ESP8266": ("nodemcuv2", esp8266_boards), + "BK72XX": ("cb2s", bk72xx_boards), + "LN882X": ("wl2s", ln882x_boards), + "RTL87XX": ("wr3", rtl87xx_boards), + "RP2": ("rpipicow", rp2_boards), + } + example, boards_module = example_boards[platform] + safe_print(f'For example "{color(AnsiFore.BOLD_WHITE, example)}".') + boards_list = boards_module.BOARDS.items() boards = [] safe_print("Options:") diff --git a/esphome/writer.py b/esphome/writer.py index a9c072f156..b7eeec916d 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -653,6 +653,22 @@ def clean_all(configuration: list[str]): elif item.is_dir() and item.name != "storage": rmtree(item) + # The native toolchain installs live in a machine-global cache dir that + # the per-config loop above can't reach. Wipe the default cache root + # (also catches leftovers from older install layouts), then the resolved + # install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI) + # that live outside it. + import platformdirs + + from esphome.components.nrf52.framework import get_sdk_nrf_tools_path + from esphome.espidf.framework import get_idf_tools_path + + cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve() + for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()): + if install_path.is_dir(): + _LOGGER.info("Deleting %s", install_path) + rmtree(install_path) + # Clean PlatformIO project files try: from platformio.project.config import ProjectConfig diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index bfe1fb0136..c2db9b97ed 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -51,6 +51,29 @@ _load_listeners: list[Callable[[Path], None]] = [] DocumentPath = list[str | int] +# Key under CORE.data used to accumulate keys that a `<<` merge silently +# dropped. The warning is emitted later (see esphome.config.validate_config), +# because the esphome: option that suppresses it isn't known while parsing. +_MERGE_WARNINGS_KEY = "yaml_dropped_merge_keys" + + +def _record_dropped_merge_key(parent_file: Path, key: Any) -> None: + """Record a mapping key that a ``<<`` merge silently dropped. + + Merge keys follow the YAML spec's shallow, first-wins semantics: a key that + already exists in the mapping (or came from an earlier merge) is discarded + rather than deep-merged the way ``packages:`` would combine it. We collect + these so a single warning can be emitted once the config is loaded. + """ + esp_range = getattr(key, "esp_range", None) + location = str(esp_range.start_mark) if esp_range is not None else str(parent_file) + CORE.data.setdefault(_MERGE_WARNINGS_KEY, []).append((str(key), location)) + + +def take_dropped_merge_keys() -> list[tuple[str, str]]: + """Return and clear the keys dropped during ``<<`` merges so far.""" + return CORE.data.pop(_MERGE_WARNINGS_KEY, []) + class SensitiveStr(str): """Marker subclass for validated strings that should be masked in @@ -551,6 +574,10 @@ class ESPHomeLoaderMixin: # is expected to contain mapping nodes and each of these nodes is merged in # turn according to its order in the sequence. Keys in mapping nodes earlier # in the sequence override keys specified in later mapping nodes." + # + # This is a silent shallow drop (unlike `packages:`, which deep-merges). + # Record it so a warning can be emitted after the config loads. + _record_dropped_merge_key(self.name, key) continue pairs.append((key, value)) # Add key node to seen keys, for sequence merge values. @@ -813,17 +840,22 @@ def _load_yaml_internal_with_type( loader.dispose() -def dump(dict_, show_secrets=False, sort_keys=False): - """Dump YAML to a string and remove null.""" +def dump(dict_, show_secrets=False, sort_keys=False, relative_to: Path | None = None): + """Dump YAML to a string and remove null. + + When ``relative_to`` is given, Path values are dumped relative to that + directory (POSIX form) so the output is machine independent. + """ if show_secrets: _SECRET_VALUES.clear() _SECRET_CACHE.clear() - # Per-call subclass so the redaction flag doesn't leak across calls. + # Per-call subclass so the flags don't leak across calls. # (``_SECRET_VALUES`` / ``_SECRET_CACHE`` remain module globals; YAML - # processing is single-threaded today, so this isolates only the flag.) + # processing is single-threaded today, so this isolates only the flags.) class _Dumper(ESPHomeDumper): _redact_sensitive = not show_secrets + _relative_to = relative_to return yaml.dump( dict_, @@ -975,9 +1007,13 @@ def format_path(path: DocumentPath, current_obj: Any) -> str: class ESPHomeDumper(yaml.SafeDumper): - # Default for the base class; per-call subclass in ``dump()`` overrides. + # Defaults for the base class; per-call subclass in ``dump()`` overrides. # When True, ``represent_sensitive`` wraps values in ANSI conceal codes. _redact_sensitive: bool = False + # When set, ``represent_path`` dumps Path values relative to this + # directory (in POSIX form) so the output does not depend on where the + # config lives on the machine that produced it. + _relative_to: Path | None = None def represent_mapping(self, tag, mapping, flow_style=None): value = [] @@ -1013,6 +1049,21 @@ class ESPHomeDumper(yaml.SafeDumper): return self.represent_secret(value) return self.represent_scalar(tag="tag:yaml.org,2002:str", value=str(value)) + def represent_path(self, value: Path) -> yaml.ScalarNode: + if self._relative_to is not None: + # Normalize both sides lexically (no symlink resolution) so ".." + # segments do not defeat the prefix match, and walk up so files + # referenced outside the anchor directory stay relative too. A + # path that still cannot be relativized (e.g. a different drive) + # keeps its POSIX form so separators stay stable across OSes. + path = Path(os.path.normpath(value)) + with suppress(ValueError): + path = path.relative_to( + os.path.normpath(self._relative_to), walk_up=True + ) + return self.represent_stringify(path.as_posix()) + return self.represent_stringify(value) + def represent_sensitive(self, value: SensitiveStr) -> yaml.ScalarNode: # Only the redact-and-not-a-secret branch is unique to sensitive # values; otherwise let ``represent_stringify`` handle ``!secret`` @@ -1111,5 +1162,5 @@ ESPHomeDumper.add_multi_representer(Extend, ESPHomeDumper.represent_extend) ESPHomeDumper.add_multi_representer(Remove, ESPHomeDumper.represent_remove) ESPHomeDumper.add_multi_representer(core.ID, ESPHomeDumper.represent_id) ESPHomeDumper.add_multi_representer(uuid.UUID, ESPHomeDumper.represent_stringify) -ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_stringify) +ESPHomeDumper.add_multi_representer(Path, ESPHomeDumper.represent_path) ESPHomeDumper.add_multi_representer(IncludeFile, ESPHomeDumper.represent_include_file) diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index e4b9abb976..04075ec4c1 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -62,14 +62,12 @@ TXT_RECORD_VERSION = b"version" class DiscoveredImport: """An importable device discovered via mDNS ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — surfaces these as - "discovered devices" on the new dashboard's adoption flow. + Used by device-builder (esphome/device-builder), which surfaces these as + "discovered devices" on its adoption flow. Fields are populated from TXT records on the broadcast service info (see :class:`DashboardImportDiscovery`). Coordinate before - adding/removing fields — both consumers persist them. + adding/removing fields — the consumer persists them. """ friendly_name: str | None @@ -87,11 +85,9 @@ class DashboardBrowser(AsyncServiceBrowser): class DashboardImportDiscovery: """Track importable devices announcing on ``_esphomelib._tcp.local.``. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — wired up alongside - the dashboard's own ``ServiceBrowser`` to populate the - "Discovered devices" panel and the adoption flow. + Used by device-builder (esphome/device-builder), which wires it up + alongside its own ``ServiceBrowser`` to populate the + "Discovered devices" panel and the adoption flow. The class maintains ``import_state: dict[str, DiscoveredImport]`` keyed by the mDNS service name. ``on_update`` is invoked with @@ -262,11 +258,9 @@ async def async_resolve_hosts( class AsyncEsphomeZeroconf(AsyncZeroconf): """ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper. - Used by: - - esphome.dashboard (legacy dashboard) - - device-builder (esphome/device-builder) — drives both the live - mDNS browser and the per-sweep ``async_resolve_host`` fallback - for non-API devices that don't broadcast esphomelib. + Used by device-builder (esphome/device-builder), which drives both the live + mDNS browser and the per-sweep ``async_resolve_host`` fallback + for non-API devices that don't broadcast esphomelib. Coordinate before adding required constructor args or changing the ``async_resolve_host`` signature — device-builder calls it diff --git a/platformio.ini b/platformio.ini index 862b7a7dbe..061e92a64a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -104,7 +104,7 @@ build_unflags = [common:idf-component-libs] lib_deps = esphome/dlms_parser@1.1.0 ; dlms_meter - bblanchon/ArduinoJson@7.4.2 ; json + bblanchon/ArduinoJson@7.4.3 ; json lvgl/lvgl@9.5.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. @@ -206,7 +206,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git#v1.4.0-gcc14-arduinopico460 platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.0/rp2040-5.6.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/5.6.1/rp2040-5.6.1.zip framework = arduino lib_deps = @@ -224,7 +224,7 @@ build_unflags = ; This are common settings for the LibreTiny (all variants) using Arduino. [common:libretiny-arduino] extends = common:arduino -platform = https://github.com/libretiny-eu/libretiny.git#v1.12.1 +platform = https://github.com/libretiny-eu/libretiny.git#v1.13.0 framework = arduino lib_compat_mode = soft lib_deps = @@ -525,7 +525,7 @@ build_unflags = [env:ln882h-arduino] extends = common:libretiny-arduino -board = generic-ln882hki +board = generic-ln882h build_flags = ${common:libretiny-arduino.build_flags} ${flags:runtime.build_flags} diff --git a/pyproject.toml b/pyproject.toml index a292377835..f38633b4ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==82.0.1", "wheel>=0.43,<0.48"] +requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Home Automation", ] -requires-python = ">=3.11.0,<3.15" +requires-python = ">=3.12.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -62,7 +62,7 @@ addopts = [ ] [tool.pylint.MAIN] -py-version = "3.11" +py-version = "3.12" ignore = [ "api_pb2.py", ] @@ -106,7 +106,7 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" -target-version = "py311" +target-version = "py312" exclude = ['generated'] [tool.ruff.lint] diff --git a/requirements.txt b/requirements.txt index 2510ca61e9..b36e70ef5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,34 +1,32 @@ -cryptography==48.0.1 +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.4 # from time tzdata>=2026.2 # from time pyserial==3.5 platformio==6.1.19 -esptool==5.3.0 +esptool==5.3.1 click==8.3.3 -esphome-dashboard==20260425.0 -aioesphomeapi==45.3.1 +aioesphomeapi==45.6.0 zeroconf==0.150.0 -puremagic==1.30 +puremagic==2.2.0 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 +pillow==12.3.0 +resvg-py==0.3.3 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 -smpclient==6.0.0 +smpclient==7.2.0 requests==2.34.2 -py7zr==1.1.0 +py7zr==1.1.3 +platformdirs==4.10.0 # native esp-idf toolchain global cache dir # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.6.3 +argcomplete>=3.7.0 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..ebd93ea390 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.20 # 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/analyze_component_buses.py b/script/analyze_component_buses.py index 8eb80d9943..a6ccb79544 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -90,6 +90,7 @@ ISOLATED_COMPONENTS = { "openthread_info": "Conflicts with wifi: used by most components", "matrix_keypad": "Needs isolation due to keypad", "microphone": "Defines PDM microphone requiring I2S port 0 - conflicts with micro_wake_word PDM mic when merged", + "mipi_rgb": "RGB display occupies many GPIOs (including ones used by the shared i2c bus) that conflict when merged with other bus components", "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 974957245a..f6dcf00851 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -390,16 +390,6 @@ def fix_mapping(): output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config -def fix_image(): - if "image" not in output: - return - from esphome.components.image import IMAGE_SCHEMA - - config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA") - config["is_list"] = True - output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config - - def fix_menu(): if "display_menu_base" not in output: return @@ -763,7 +753,6 @@ def build_schema(): fix_font() fix_globals() fix_mapping() - fix_image() add_logger_tags() shrink() fix_menu() @@ -785,6 +774,29 @@ def build_schema(): # bundle core inside esphome data["esphome"]["core"] = data.pop("core")["core"] + # Surface deprecated component aliases (declared via ``ALIASES = [...]`` + # on the canonical component) so language servers / dashboard + # autocomplete still accept legacy top-level keys instead of flagging + # them as unknown. Each alias gets its own bundle that mirrors the + # canonical schema; ``alias_of`` and the optional ``removal_version`` + # metadata let consumers render a deprecation hint and point users at + # the canonical name. Without this, configs migrated only at runtime + # (via the ``_resolve_component_aliases`` pre-pass) would still light + # up as errors in the editor. + for domain, manifest in components.items(): + aliases = manifest.aliases + if not aliases or domain not in data: + continue + canonical_bundle = data[domain].get(domain) + if canonical_bundle is None: + continue + for alias in aliases: + alias_entry = dict(canonical_bundle) + alias_entry["alias_of"] = domain + if manifest.alias_removal_version is not None: + alias_entry["removal_version"] = manifest.alias_removal_version + data[alias] = {alias: alias_entry} + if GENERATED_ID_TYPES: print( "Unconsumed id_type matchers:", diff --git a/script/ci-custom.py b/script/ci-custom.py index 78ff6cf781..4b16734ebe 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -259,14 +259,7 @@ def lint_executable_bit(fname: Path) -> str | None: return None -@lint_content_find_check( - "\t", - only_first=True, - exclude=[ - "esphome/dashboard/static/ace.js", - "esphome/dashboard/static/ext-searchbox.js", - ], -) +@lint_content_find_check("\t", only_first=True) def lint_tabs(fname, line, col, content): return "File contains tab character. Please convert tabs to spaces." @@ -276,7 +269,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." @@ -562,7 +555,7 @@ def lint_constants_usage(): # Maximum allowed CONF_ constants in esphome/const.py. # This file is frozen — new constants go in esphome/components/const/__init__.py. # Decrease this number when constants are moved out of const.py. -CONST_PY_MAX_CONF = 1013 +CONST_PY_MAX_CONF = 1015 @lint_content_check(include=["esphome/const.py"]) @@ -628,6 +621,9 @@ def convert_path_to_relative(abspath, current): "esphome/components/web_server/__init__.py", # const.py has absolute import in docstring example for external components "esphome/components/esp8266/const.py", + # rp2040/__init__.py is the deprecation shim that documents the canonical + # rp2 module path and its own legacy import paths in docstrings/comments. + "esphome/components/rp2040/__init__.py", ], ) def lint_relative_py_import(fname: Path, line, col, content): @@ -657,13 +653,13 @@ def lint_relative_py_import(fname: Path, line, col, content): "esphome/components/async_tcp/async_tcp.h", "esphome/components/esp32/core.cpp", "esphome/components/esp8266/core.cpp", - "esphome/components/rp2040/core.cpp", + "esphome/components/rp2/core.cpp", "esphome/components/libretiny/core.cpp", "esphome/components/host/core.cpp", "esphome/components/zephyr/core.cpp", "esphome/components/esp32/helpers.cpp", "esphome/components/esp8266/helpers.cpp", - "esphome/components/rp2040/helpers.cpp", + "esphome/components/rp2/helpers.cpp", "esphome/components/libretiny/helpers.cpp", "esphome/components/host/helpers.cpp", "esphome/components/zephyr/helpers.cpp", diff --git a/script/ci_memory_impact_extract.py b/script/ci_memory_impact_extract.py index feacc2b1af..20a737cdbf 100755 --- a/script/ci_memory_impact_extract.py +++ b/script/ci_memory_impact_extract.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Extract memory usage statistics from ESPHome build output. -This script parses the PlatformIO build output to extract RAM and flash -usage statistics for a compiled component. It's used by the CI workflow to +This script parses the build output to extract RAM and flash usage +statistics for a compiled component. It's used by the CI workflow to compare memory usage between branches. The script reads compile output from stdin and looks for the standard @@ -10,6 +10,13 @@ PlatformIO output format: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) +or the linker memory usage table printed by Zephyr native builds +(e.g. nRF52 with the sdk-nrf toolchain): + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + IDT_LIST: 0 GB 32 KB 0.00% + Optionally performs detailed memory analysis if a build directory is provided. """ @@ -34,20 +41,43 @@ _RAM_PATTERN = re.compile(r"RAM:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes" _FLASH_PATTERN = re.compile(r"Flash:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes") _BUILD_PATH_PATTERN = re.compile(r"Build path: (.+)") +# Zephyr native builds print the GNU ld --print-memory-usage table instead of +# the PlatformIO summary. Only the FLASH and RAM regions are real memory +# (IDT_LIST is a build-time pseudo-region discarded from the final image). +# Each cell is humanized to the largest unit that divides evenly, so used +# sizes are not always plain bytes (zero prints as "0 GB"). +_ZEPHYR_RAM_PATTERN = re.compile( + r"^\s*RAM:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_FLASH_PATTERN = re.compile( + r"^\s*FLASH:\s+(\d+)\s*([KMG]?B)\s+\d+\s*[KMG]?B\s+\d+\.\d+%", re.MULTILINE +) +_ZEPHYR_UNIT_MULTIPLIERS = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + + +def _zephyr_bytes(matches: list[tuple[str, str]]) -> int: + """Sum humanized (value, unit) pairs from the Zephyr memory table.""" + return sum(int(value) * _ZEPHYR_UNIT_MULTIPLIERS[unit] for value, unit in matches) + def extract_from_compile_output( output_text: str, ) -> tuple[int | None, int | None, str | None]: - """Extract memory usage and build directory from PlatformIO compile output. + """Extract memory usage and build directory from compile output. Supports multiple builds (for component groups or isolated components). When test_build_components.py creates multiple builds, this sums the memory usage across all builds. - Looks for lines like: + Looks for PlatformIO lines like: RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes) Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes) + and Zephyr (native west build) linker table rows like: + Memory region Used Size Region Size %age Used + FLASH: 90624 B 796 KB 11.12% + RAM: 22432 B 256 KB 8.56% + Also extracts build directory from lines like: INFO Compiling app... Build path: /path/to/build @@ -61,12 +91,20 @@ def extract_from_compile_output( ram_matches = _RAM_PATTERN.findall(output_text) flash_matches = _FLASH_PATTERN.findall(output_text) - if not ram_matches or not flash_matches: + # Zephyr native builds print the linker memory table instead + zephyr_ram_matches = _ZEPHYR_RAM_PATTERN.findall(output_text) + zephyr_flash_matches = _ZEPHYR_FLASH_PATTERN.findall(output_text) + + if not (ram_matches or zephyr_ram_matches) or not ( + flash_matches or zephyr_flash_matches + ): return None, None, None # Sum all builds (handles multiple component groups) total_ram = sum(int(match) for match in ram_matches) total_flash = sum(int(match) for match in flash_matches) + total_ram += _zephyr_bytes(zephyr_ram_matches) + total_flash += _zephyr_bytes(zephyr_flash_matches) # Extract build directory from ESPHome's explicit build path output # Look for: INFO Compiling app... Build path: /path/to/build @@ -202,20 +240,23 @@ def main() -> int: ) if ram_bytes is None or flash_bytes is None: - print("Failed to extract memory usage from compile output", file=sys.stderr) - print("Expected lines like:", file=sys.stderr) print( - " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)", - file=sys.stderr, - ) - print( - " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)", + "Failed to extract memory usage from compile output\n" + "Expected lines like:\n" + " RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)\n" + " Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)\n" + "or a Zephyr linker memory usage table like:\n" + " Memory region Used Size Region Size %age Used\n" + " FLASH: 90624 B 796 KB 11.12%\n" + " RAM: 22432 B 256 KB 8.56%", file=sys.stderr, ) return 1 # Count how many builds were found - num_builds = len(_RAM_PATTERN.findall(compile_output)) + num_builds = len(_RAM_PATTERN.findall(compile_output)) + len( + _ZEPHYR_RAM_PATTERN.findall(compile_output) + ) if num_builds > 1: print( diff --git a/script/clang-tidy b/script/clang-tidy index 1416b9b332..7df46cb2d2 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -145,14 +145,16 @@ def clang_options(idedata): # defines cmd.extend(f"-D{define}" for define in idedata["defines"]) - # add toolchain include directories using -isystem to suppress their errors + # toolchain include directories, using -isystem to suppress their errors # idedata contains include directories for all toolchains of this platform, only use those from the one in use toolchain_dir = os.path.normpath(f"{idedata['cxx_path']}/../../") + toolchain_includes = [] for directory in idedata["includes"]["toolchain"]: if directory.startswith(toolchain_dir) and "picolibc" not in directory: - cmd.extend(["-isystem", directory]) + toolchain_includes.extend(["-isystem", directory]) - # add library include directories using -isystem to suppress their errors + # library include directories, using -isystem to suppress their errors + build_includes = [] for directory in list(idedata["includes"]["build"]): # skip our own directories, we add those later if ( @@ -166,7 +168,16 @@ def clang_options(idedata): ) or (directory.startswith(f"{root_path}") and "/.pio/" in directory) ): - cmd.extend(["-isystem", directory]) + build_includes.extend(["-isystem", directory]) + + if "zephyr" in triplet: + # Zephyr's POSIX layer shadows libc headers (sys/select.h, ...) with + # coherently-guarded versions; the real build searches the Zephyr + # include dirs before the toolchain's, and the shadowed headers clash + # (e.g. newlib's sigset_t vs Zephyr's) in the opposite order. + cmd.extend(build_includes + toolchain_includes) + else: + cmd.extend(toolchain_includes + build_includes) # add the esphome include directory using -I cmd.extend(["-I", root_path]) diff --git a/script/clang_tidy_hash.py b/script/clang_tidy_hash.py old mode 100755 new mode 100644 index 62f76246b4..57ca90711c --- a/script/clang_tidy_hash.py +++ b/script/clang_tidy_hash.py @@ -1,66 +1,34 @@ -#!/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", + "esphome/components/esp32/__init__.py", + "esphome/components/nrf52/__init__.py", +) - -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 +48,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..061485c76c 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, @@ -160,7 +160,8 @@ class Platform(StrEnum): BK72XX_ARD = "bk72xx-ard" # LibreTiny BK7231N RTL87XX_ARD = "rtl87xx-ard" # LibreTiny RTL8720x LN882X_ARD = "ln882x-ard" # LibreTiny LN882x - RP2040_ARD = "rp2040-ard" # Raspberry Pi Pico + RP2040_ARD = "rp2040-ard" # RP2 family, RP2040 chip (Pico / Pico W) + RP2350_ARD = "rp2350-ard" # RP2 family, RP2350 chip (Pico 2 / Pico 2 W) NRF52_ZEPHYR = "nrf52-adafruit" # Nordic nRF52 (Zephyr) @@ -190,7 +191,8 @@ MEMORY_IMPACT_PLATFORM_PREFERENCE = [ Platform.BK72XX_ARD, # LibreTiny BK7231N Platform.RTL87XX_ARD, # LibreTiny RTL8720x Platform.LN882X_ARD, # LibreTiny LN882x - Platform.RP2040_ARD, # Raspberry Pi Pico + Platform.RP2040_ARD, # Raspberry Pi Pico (RP2040) + Platform.RP2350_ARD, # Raspberry Pi Pico 2 (RP2350) Platform.NRF52_ZEPHYR, # Nordic nRF52 (Zephyr) ] @@ -280,23 +282,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 +308,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 +321,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 +468,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 +492,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 +582,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( @@ -852,7 +861,8 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: - *_libretiny.cpp, *_bk72*.* -> BK72XX (LibreTiny) - *_rtl87*.* -> RTL87XX (LibreTiny Realtek) - *_ln882*.* -> LN882X (LibreTiny Lightning) - - *_pico.cpp, *_rp2040.* -> RP2040_ARD + - *_rp2350*.*, *_pico2*.* -> RP2350_ARD (RP2 family, RP2350 chip) + - *_rp2040*.*, *_pico*.* -> RP2040_ARD (RP2 family, RP2040 chip) Args: filename: File path to check @@ -894,8 +904,14 @@ def _detect_platform_hint_from_filename(filename: str) -> Platform | None: if "libretiny" in filename_lower or "bk72" in filename_lower: return Platform.BK72XX_ARD - # RP2040 / Raspberry Pi Pico - if "pico" in filename_lower or "rp2040" in filename_lower: + # RP2 family (Raspberry Pi Pico): explicit chip names only. Family- + # wide files (named ``_rp2.*``) are shared between RP2040 and RP2350 + # and intentionally don't preferentially route to either chip. + # Check the RP2350 patterns first since ``pico2`` substring-matches + # ``pico``. + if "rp2350" in filename_lower or "pico2" in filename_lower: + return Platform.RP2350_ARD + if "rp2040" in filename_lower or "pico" in filename_lower: return Platform.RP2040_ARD # nRF52 / Zephyr @@ -1177,8 +1193,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 +1204,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 +1259,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 +1304,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 @@ -1319,7 +1347,7 @@ def main() -> None: # Split components into batches for CI testing # This intelligently groups components with similar bus configurations - component_test_batches: list[str] + component_test_batches: list[dict[str, Any]] = [] if changed_components_with_tests: tests_dir = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH @@ -1344,10 +1372,20 @@ def main() -> None: batch_size=COMPONENT_TEST_BATCH_SIZE, directly_changed=batch_directly_changed, ) - # Convert batches to space-separated strings for CI matrix - component_test_batches = [" ".join(batch) for batch in batches] - else: - component_test_batches = [] + # Convert batches to CI matrix entries: the component list plus which + # native toolchain installs the batch's test platforms need, so the + # workflow only restores the matching multi-GB toolchain caches. + for batch in batches: + platforms: set[str] = set() + for component in batch: + platforms.update(get_component_test_platforms(component)) + component_test_batches.append( + { + "components": " ".join(batch), + "needs_idf": any(p.startswith("esp32") for p in platforms), + "needs_nrf": any(p.startswith("nrf52") for p in platforms), + } + ) output: dict[str, Any] = { "core_ci": run_core_ci, @@ -1360,8 +1398,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/script/generate-rp2040-boards.py b/script/generate-rp2-boards.py similarity index 77% rename from script/generate-rp2040-boards.py rename to script/generate-rp2-boards.py index 1b4846fd2b..94a5cc018a 100755 --- a/script/generate-rp2040-boards.py +++ b/script/generate-rp2-boards.py @@ -8,14 +8,14 @@ import subprocess import sys import tempfile -from esphome.components.rp2040 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION -from esphome.components.rp2040.generate_boards import generate +from esphome.components.rp2 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION +from esphome.components.rp2.generate_boards import generate from esphome.helpers import write_file_if_changed ver = RECOMMENDED_ARDUINO_FRAMEWORK_VERSION version_tag: str = f"{ver.major}.{ver.minor}.{ver.patch}" root: Path = Path(__file__).parent.parent -boards_file_path: Path = root / "esphome" / "components" / "rp2040" / "boards.py" +boards_file_path: Path = root / "esphome" / "components" / "rp2" / "boards.py" def main(check: bool) -> None: @@ -42,10 +42,10 @@ def main(check: bool) -> None: if check: existing_content: str = boards_file_path.read_text(encoding="utf-8") if existing_content != content: - print("esphome/components/rp2040/boards.py is not up to date.") - print("Please run `script/generate-rp2040-boards.py`") + print("esphome/components/rp2/boards.py is not up to date.") + print("Please run `script/generate-rp2-boards.py`") sys.exit(1) - print("esphome/components/rp2040/boards.py is up to date") + print("esphome/components/rp2/boards.py is up to date") elif write_file_if_changed(boards_file_path, content): print("RP2040 boards updated successfully.") diff --git a/script/helpers.py b/script/helpers.py index fc2a3607fb..0086a00e85 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -238,6 +238,72 @@ class _ConflictWalk: rejects: set[str] +@cache +def _get_test_config_components(component: str, platform: str) -> frozenset[str]: + """Return the components referenced by a component's test config for a platform. + + Loads ``tests/components//test..yaml`` and extracts the + top-level component keys (and list ``platform:`` values). This lets the + conflict splitter see components that are only pulled in via a test config + (e.g. nRF52 ``network`` tests that also enable ``openthread``), which a + purely static AUTO_LOAD/CONFLICTS_WITH parse cannot discover -- notably for + components like ``api`` whose ``AUTO_LOAD`` is a callable. + + Failures (missing file, parse error) are treated as empty so the splitter + never crashes on a malformed or absent test config. + """ + from esphome import yaml_util + + test_file = ( + Path(root_path) / "tests" / "components" / component / f"test.{platform}.yaml" + ) + if not test_file.exists(): + return frozenset() + try: + config = yaml_util.load_yaml(test_file) + except Exception: # noqa: BLE001 - never let a bad test config crash grouping + # Matches analyze_component_buses, which loads these same files and + # silently tolerates parse failures; surfacing it only here would be + # inconsistent and noisy. + return frozenset() + if not isinstance(config, dict): + return frozenset() + return frozenset(_extract_components_from_yaml(config)) + + +@cache +def _conflict_walk(comp: str, platform: str) -> _ConflictWalk: + """Build the platform-aware conflict walk for a single component. + + Seeds the walk with the component itself plus any components pulled in via + its ``test..yaml`` config, then folds in each seed's static + AUTO_LOAD closure and CONFLICTS_WITH declarations. Cached per + ``(component, platform)`` since the test-config seeds are platform-specific. + """ + seeds = {comp} | set(_get_test_config_components(comp, platform)) + walk = _ConflictWalk(loaded=set(seeds), rejects=set()) + stack = list(seeds) + while stack: + metadata = parse_component_metadata(stack.pop()) + walk.rejects |= metadata.conflicts_with + new = metadata.auto_load - walk.loaded + walk.loaded |= new + stack.extend(new) + return walk + + +def components_conflict(a: str, b: str, platform: str) -> bool: + """Return True if components ``a`` and ``b`` cannot share a build on ``platform``. + + Uses the same platform-aware conflict walk as :func:`split_conflicting_groups` + so callers (e.g. the no-bus redistribution in ``test_build_components.py``) + agree with how groups were originally split. The conflict relation is + symmetric even when only one side declares CONFLICTS_WITH. + """ + wa, wb = _conflict_walk(a, platform), _conflict_walk(b, platform) + return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint(wa.loaded) + + def split_conflicting_groups( grouped_components: dict[tuple[str, str], list[str]], ) -> dict[tuple[str, str], list[str]]: @@ -250,33 +316,24 @@ def split_conflicting_groups( conflict relation is treated as symmetric even when only one side declares it (e.g. ethernet rejects wifi but wifi does not declare the reverse). + + The walk is platform-aware: in addition to the static AUTO_LOAD closure, + each ``(component, platform)`` walk is seeded with the components found in + that component's ``test..yaml`` config. This catches conflicts + that only exist on a given platform and are expressed through the test + config rather than static metadata -- e.g. on nRF52 the ``network``/``api`` + test configs also enable ``openthread``, which ``zigbee`` declares a + conflict with, so ``api`` and ``zigbee`` end up split there. On ESP32 those + test configs have no ``openthread``, so the components still group together. """ - batch = {c for comps in grouped_components.values() for c in comps} - - walks: dict[str, _ConflictWalk] = {} - for comp in batch: - walk = _ConflictWalk(loaded={comp}, rejects=set()) - stack = [comp] - while stack: - metadata = parse_component_metadata(stack.pop()) - walk.rejects |= metadata.conflicts_with - new = metadata.auto_load - walk.loaded - walk.loaded |= new - stack.extend(new) - walks[comp] = walk - - def conflicts(a: str, b: str) -> bool: - wa, wb = walks[a], walks[b] - return not wa.rejects.isdisjoint(wb.loaded) or not wb.rejects.isdisjoint( - wa.loaded - ) - result: dict[tuple[str, str], list[str]] = {} for (platform, signature), components in grouped_components.items(): buckets: list[list[str]] = [] for comp in components: for bucket in buckets: - if not any(conflicts(comp, other) for other in bucket): + if not any( + components_conflict(comp, other, platform) for other in bucket + ): bucket.append(comp) break else: diff --git a/script/helpers_zephyr.py b/script/helpers_zephyr.py index 66ef6ffc98..c26ad7f2cd 100644 --- a/script/helpers_zephyr.py +++ b/script/helpers_zephyr.py @@ -1,59 +1,32 @@ +"""Load clang-tidy idedata for the nrf52/Zephyr environment. + +The compile commands come from a configure-only build of a minimal Zephyr +project using the native sdk-nrf toolchain (see +``esphome.components.nrf52.clang_tidy``); this module extracts the include +paths, defines and compiler flags clang-tidy needs from them. +""" + import json +import os from pathlib import Path import re +import shlex import subprocess def load_idedata(environment, temp_folder, platformio_ini): - build_environment = environment.replace("-tidy", "") - build_dir = Path(temp_folder) / f"build-{build_environment}" - Path(build_dir).mkdir(exist_ok=True) - Path(build_dir / "platformio.ini").write_text( - Path(platformio_ini).read_text(encoding="utf-8"), encoding="utf-8" - ) - esphome_dir = Path(build_dir / "esphome") - esphome_dir.mkdir(exist_ok=True) - Path(esphome_dir / "main.cpp").write_text( - """ -#include -int main() { return 0;} -extern "C" void zboss_signal_handler() {}; -""", - encoding="utf-8", - ) - zephyr_dir = Path(build_dir / "zephyr") - zephyr_dir.mkdir(exist_ok=True) - Path(zephyr_dir / "prj.conf").write_text( - """ -CONFIG_NEWLIB_LIBC=y -CONFIG_BT=y -CONFIG_ADC=y -#mcumgr begin -CONFIG_NET_BUF=y -CONFIG_ZCBOR=y -CONFIG_MCUMGR=y -CONFIG_MCUMGR_GRP_IMG=y -CONFIG_IMG_MANAGER=y -CONFIG_STREAM_FLASH=y -CONFIG_FLASH_MAP=y -CONFIG_FLASH=y -CONFIG_IMG_ERASE_PROGRESSIVELY=y -CONFIG_BOOTLOADER_MCUBOOT=y -CONFIG_MCUMGR_MGMT_NOTIFICATION_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_STATUS_HOOKS=y -CONFIG_MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK=y -CONFIG_MCUMGR_TRANSPORT_UART=y -#mcumgr end -#zigbee begin -CONFIG_ZIGBEE=y -CONFIG_CRYPTO=y -CONFIG_NVS=y -CONFIG_SETTINGS=y -#zigbee end -""", - encoding="utf-8", - ) - subprocess.run(["pio", "run", "-e", build_environment, "-d", build_dir], check=True) + if explicit := os.environ.get("ESPHOME_ZEPHYR_COMPILE_COMMANDS"): + compile_commands_path = Path(explicit) + else: + from esphome.components.nrf52.clang_tidy import generate_compile_commands + + work_dir = (Path(temp_folder) / f"zephyr-{environment}").resolve() + compile_commands_path = generate_compile_commands( + work_dir, Path(platformio_ini) + ) + + if not compile_commands_path.is_file(): + raise RuntimeError(f"compile_commands.json not found: {compile_commands_path}") def extract_include_paths(command): include_paths = [] @@ -62,7 +35,7 @@ CONFIG_SETTINGS=y split_strings = re.split( r"\s*-\s*(?:I|isystem)", list(filter(lambda x: x, match))[0] ) - include_paths.append(split_strings[1]) + include_paths.append(split_strings[1].strip()) return include_paths def extract_defines(command): @@ -74,15 +47,6 @@ CONFIG_SETTINGS=y if not any(match.startswith(prefix) for prefix in ignore_prefixes) ] - def find_cxx_path(commands): - for entry in commands: - command = entry["command"] - cxx_path = command.split()[0] - if not cxx_path.endswith("++"): - continue - return cxx_path - return None - def get_builtin_include_paths(compiler): result = subprocess.run( [compiler, "-E", "-x", "c++", "-", "-v"], @@ -105,47 +69,48 @@ CONFIG_SETTINGS=y return include_paths def extract_cxx_flags(command): - # Extracts CXXFLAGS from the command string, excluding includes and defines. + # Extracts CXXFLAGS from the command string, excluding includes and + # defines. Anchored per token: a substring match would extract a bogus + # "-format-zero-length" from -Wno-format-zero-length. flag_pattern = re.compile( - r"(-O[0-3s]|-g|-std=[^\s]+|-Wall|-Wextra|-Werror|--[^\s]+|-f[^\s]+|-m[^\s]+|-imacros\s*[^\s]+)" + r"^(-O[0-3s]|-g|-std=.+|-Wall|-Wextra|-Werror|--.+|-f.+|-m.+|-imacros.+)$" ) - return [ - match.replace("-imacros ", "-imacros") - for match in flag_pattern.findall(command) - ] + flags = [] + tokens = shlex.split(command) + for i, token in enumerate(tokens): + if token == "-imacros" and i + 1 < len(tokens): + flags.append(f"-imacros{tokens[i + 1]}") + elif flag_pattern.match(token): + flags.append(token) + return flags def transform_to_idedata_format(compile_commands): - cxx_path = find_cxx_path(compile_commands) - idedata = { + # Use only the tidy app TU (main.cpp): as the app target, its compile + # command already carries the full Zephyr include set. Unioning every + # TU instead would drag in per-library internal include dirs (e.g. the + # Zephyr POSIX shim, whose signal.h redefines newlib's sigset_t) that + # no ESPHome source compiles against. + entry = next( + (e for e in compile_commands if e["file"].endswith("main.cpp")), None + ) + if entry is None: + raise RuntimeError("tidy main.cpp not found in compile_commands.json") + command = entry["command"] + # Find the compiler by name: the command may be prefixed with a + # launcher (Zephyr auto-enables ccache when present). + cxx_path = next((t for t in shlex.split(command) if t.endswith("++")), None) + if cxx_path is None: + raise RuntimeError(f"no C++ compiler in compile command: {command}") + + return { "includes": { "toolchain": get_builtin_include_paths(cxx_path), - "build": set(), + "build": extract_include_paths(command), }, - "defines": set(), + "defines": extract_defines(command), "cxx_path": cxx_path, - "cxx_flags": set(), + "cxx_flags": extract_cxx_flags(command), } - for entry in compile_commands: - command = entry["command"] - exec = command.split()[0] - if exec != cxx_path: - continue - - idedata["includes"]["build"].update(extract_include_paths(command)) - idedata["defines"].update(extract_defines(command)) - idedata["cxx_flags"].update(extract_cxx_flags(command)) - - # Convert sets to lists for JSON serialization - idedata["includes"]["build"] = list(idedata["includes"]["build"]) - idedata["defines"] = list(idedata["defines"]) - idedata["cxx_flags"] = list(idedata["cxx_flags"]) - - return idedata - - compile_commands = json.loads( - Path( - build_dir / ".pio" / "build" / build_environment / "compile_commands.json" - ).read_text(encoding="utf-8") - ) + compile_commands = json.loads(compile_commands_path.read_text(encoding="utf-8")) return transform_to_idedata_format(compile_commands) diff --git a/script/import_time_budget.json b/script/import_time_budget.json index af3aa83511..e810817507 100644 --- a/script/import_time_budget.json +++ b/script/import_time_budget.json @@ -1,5 +1,5 @@ { "target_module": "esphome.__main__", - "margin_pct": 15, - "cumulative_us": 91000 + "margin_pct": 20, + "cumulative_us": 95000 } diff --git a/script/lint-python b/script/lint-python index e4b3314d2a..6bd95778fa 100755 --- a/script/lint-python +++ b/script/lint-python @@ -139,7 +139,7 @@ def main(): print() print("Running pyupgrade...") print() - PYUPGRADE_TARGET = "--py311-plus" + PYUPGRADE_TARGET = "--py312-plus" for files in filesets: cmd = ["pyupgrade", PYUPGRADE_TARGET] + files log = get_err(*cmd) diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index 5eeeafac2a..c2be7be7fd 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -263,22 +263,39 @@ def prepare_component_body(comp_data: dict, comp_name: str, comp_dir: Path) -> d else {} ) - packages_value = comp_data.get("packages") - if isinstance(packages_value, dict): - common_bus_packages = get_common_bus_packages() - for pkg_name, pkg_value in list(packages_value.items()): - if pkg_name in common_bus_packages: - continue - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) - elif isinstance(packages_value, list): - for pkg_value in packages_value: - if isinstance(pkg_value, yaml_util.IncludeFile): - pkg_value = pkg_value.load() - if isinstance(pkg_value, dict): - comp_data = merge_config(comp_data, pkg_value) + # Expand component-specific package includes inline. A package include may + # itself pull in further component-specific packages (e.g. web_server's test + # includes common_v2, which includes common with the wifi/network config), so + # keep expanding until only common bus packages remain -- otherwise the nested + # includes are silently dropped when the packages key is removed below. + common_bus_packages = get_common_bus_packages() + while True: + packages_value = comp_data.get("packages") + expanded = False + if isinstance(packages_value, dict): + for pkg_name, pkg_value in list(packages_value.items()): + if pkg_name in common_bus_packages: + continue + # Drop before merging so a nested packages dict introduced by the + # include does not re-add this same key on the next iteration. + del packages_value[pkg_name] + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + elif isinstance(packages_value, list): + # List-style packages never contain common bus packages, so expand + # them all and drop the key entirely. + comp_data.pop("packages", None) + for pkg_value in packages_value: + if isinstance(pkg_value, yaml_util.IncludeFile): + pkg_value = pkg_value.load() + if isinstance(pkg_value, dict): + comp_data = merge_config(comp_data, pkg_value) + expanded = True + if not expanded: + break # Common bus packages are re-added once by the caller; drop them here. comp_data.pop("packages", None) diff --git a/script/setup b/script/setup index 8cad7017ff..709eaee0f3 100755 --- a/script/setup +++ b/script/setup @@ -4,7 +4,12 @@ set -e cd "$(dirname "$0")/.." -if [ ! -n "$VIRTUAL_ENV" ]; then +if [ -n "$VIRTUAL_ENV" ]; then + # A virtual environment is already active (e.g. the devcontainer's pre-provisioned + # esphome-venv). Install into it rather than creating a ./venv in the workspace. + created_venv=false +else + created_venv=true if [ -x "$(command -v uv)" ]; then uv venv --seed venv else @@ -26,4 +31,10 @@ mkdir -p .temp echo echo -echo "Virtual environment created. Run 'source venv/bin/activate' to use it." +if [ "$created_venv" = true ]; then + echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it." +else + echo "Dependencies installed into the active virtual environment:" + echo " $VIRTUAL_ENV" + echo "It is already active in this shell, so no 'source venv/bin/activate' is needed." +fi diff --git a/script/test_build_components.py b/script/test_build_components.py index 651268609e..ce2a35add3 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -40,6 +40,7 @@ from script.analyze_component_buses import ( uses_local_file_references, ) from script.helpers import ( + components_conflict, get_component_test_files, is_validate_only_file, parse_test_filename, @@ -788,14 +789,35 @@ def run_grouped_component_tests( if plat == platform and sig != NO_BUSES_SIGNATURE ] - if platform_groups: - # Distribute no_buses components round-robin across existing groups - for i, comp in enumerate(no_buses_comps): - sig, _ = platform_groups[i % len(platform_groups)] - grouped_components[(platform, sig)].append(comp) - else: - # No other groups for this platform - keep no_buses components together - grouped_components[(platform, NO_BUSES_SIGNATURE)] = no_buses_comps + # Distribute no_buses components round-robin across existing groups, + # but never place a component into a group it conflicts with. Conflict + # splitting (split_conflicting_groups) may have created sibling groups + # like "no_buses__conflict1" precisely to keep incompatible components + # apart (e.g. on nRF52, network pulls in openthread which zigbee + # conflicts with); redistribution must not silently undo that split. + leftover: list[str] = [] + for i, comp in enumerate(no_buses_comps): + placed = False + # Try groups starting at the round-robin offset to keep the spread. + for offset in range(len(platform_groups)): + sig, comps = platform_groups[(i + offset) % len(platform_groups)] + if any(components_conflict(comp, other, platform) for other in comps): + continue + # comps is the same list object stored in grouped_components, so + # this also extends the group in grouped_components. + comps.append(comp) + placed = True + break + if not placed: + leftover.append(comp) + + if leftover: + # Components that conflict with every existing group stay together in + # their own no_buses group (they were grouped before, so they don't + # conflict with each other). + grouped_components.setdefault((platform, NO_BUSES_SIGNATURE), []).extend( + leftover + ) groups_to_test = [] individual_tests = set() # Use set to avoid duplicates diff --git a/sdkconfig.defaults.esp32c6 b/sdkconfig.defaults.esp32c6 index 6dd5f4f329..63dbeffd77 100644 --- a/sdkconfig.defaults.esp32c6 +++ b/sdkconfig.defaults.esp32c6 @@ -11,4 +11,3 @@ CONFIG_OPENTHREAD_RADIO_NATIVE=y # zigbee CONFIG_ZB_ENABLED=y CONFIG_ZB_ZED=y -CONFIG_ZB_RADIO_NATIVE=y diff --git a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h index ba97e81236..b4ccd8fd00 100644 --- a/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h +++ b/tests/benchmarks/stubs/esphome/components/zwave_proxy/zwave_proxy.h @@ -16,7 +16,7 @@ class ZWaveProxy { public: api::APIConnection *get_api_connection() { return nullptr; } void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {} - void send_frame(const uint8_t *data, size_t length) {} + void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {} void api_connection_authenticated(api::APIConnection *conn) {} uint32_t get_feature_flags() const { return 0; } uint32_t get_home_id() { return 0; } diff --git a/esphome/dashboard/__init__.py b/tests/component_tests/animation/__init__.py similarity index 100% rename from esphome/dashboard/__init__.py rename to tests/component_tests/animation/__init__.py diff --git a/tests/component_tests/animation/config/anim.apng b/tests/component_tests/animation/config/anim.apng new file mode 100644 index 0000000000..927af5eb05 Binary files /dev/null and b/tests/component_tests/animation/config/anim.apng differ diff --git a/tests/component_tests/animation/config/anim.gif b/tests/component_tests/animation/config/anim.gif new file mode 100644 index 0000000000..9932e77448 Binary files /dev/null and b/tests/component_tests/animation/config/anim.gif differ diff --git a/tests/component_tests/animation/config/animation_platform_test.yaml b/tests/component_tests/animation/config/animation_platform_test.yaml new file mode 100644 index 0000000000..8de32ed593 --- /dev/null +++ b/tests/component_tests/animation/config/animation_platform_test.yaml @@ -0,0 +1,33 @@ +# New `image:` `platform: animation` form. Exercises animation/image.py through +# the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +image: + - platform: animation + id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + - platform: animation + id: test_animation_no_loop + file: anim.gif + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +psram: + mode: quad + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/config/animation_test.yaml b/tests/component_tests/animation/config/animation_test.yaml new file mode 100644 index 0000000000..1fe6ddf9a4 --- /dev/null +++ b/tests/component_tests/animation/config/animation_test.yaml @@ -0,0 +1,28 @@ +# Legacy top-level `animation:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +animation: + - id: test_animation + file: anim.gif + type: rgb565 + loop: + start_frame: 0 + end_frame: 2 + repeat: 3 + +spi: + mosi_pin: 6 + clk_pin: 7 + +psram: + mode: quad + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/animation/test_init.py b/tests/component_tests/animation/test_init.py new file mode 100644 index 0000000000..1b5dd0d54c --- /dev/null +++ b/tests/component_tests/animation/test_init.py @@ -0,0 +1,81 @@ +"""Tests for the animation image platform and the legacy `animation:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.animation import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_animation, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `animation:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/animation/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_animation_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_animation", "file": "anim.gif", "type": "rgb565"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_animation(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_animation(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: animation" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_animation_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `animation:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("animation_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "animation" in caplog.text + assert "deprecated" in caplog.text + + # setup_animation ran: Animation object constructed and loop configured. + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + + +def test_animation_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: animation` form generates codegen through the + real platform loader (animation/image.py) without any deprecation warning.""" + main_cpp = generate_main(component_config_path("animation_platform_test.yaml")) + + assert "new(test_animation) animation::Animation(" in main_cpp + assert "test_animation->set_loop(0, 2, 3);" in main_cpp + # The loop-less entry constructs the object but never configures a loop. + assert "new(test_animation_no_loop) animation::Animation(" in main_cpp + assert "test_animation_no_loop->set_loop(" not in main_cpp diff --git a/esphome/dashboard/status/__init__.py b/tests/component_tests/api/__init__.py similarity index 100% rename from esphome/dashboard/status/__init__.py rename to tests/component_tests/api/__init__.py diff --git a/tests/component_tests/api/test_homeassistant_action.py b/tests/component_tests/api/test_homeassistant_action.py new file mode 100644 index 0000000000..611353e7c5 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.py @@ -0,0 +1,28 @@ +"""Tests for arg-type selection of api user-defined services with homeassistant.action.""" + +CONFIG = "tests/component_tests/api/test_homeassistant_action.yaml" + + +def test_synchronous_chain_keeps_zero_copy_args(generate_main): + """A chain of synchronous actions keeps the non-owning StringRef arg type.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("zero_copy_args", {"message"})' in main_cpp + ) + + +def test_response_callback_args_are_owning(generate_main): + """homeassistant.action with on_success/on_error stores the trigger args + until the HomeassistantActionResponse arrives, so string args must fall + back to owning std::string; StringRef would point into the connection's + receive buffer, which is reused before the response arrives.""" + main_cpp = generate_main(CONFIG) + + assert ( + "api::UserServiceTrigger" + '("response_args", {"message"})' in main_cpp + ) + assert "api::HomeAssistantServiceCallAction" in main_cpp + assert "api::HomeAssistantServiceCallAction" not in main_cpp diff --git a/tests/component_tests/api/test_homeassistant_action.yaml b/tests/component_tests/api/test_homeassistant_action.yaml new file mode 100644 index 0000000000..4561494c9e --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_action.yaml @@ -0,0 +1,43 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + actions: + # Chain of synchronous actions that never store the args: + # keeps the zero-copy StringRef arg type. + - action: zero_copy_args + variables: + message: string + then: + - logger.log: + format: "%s" + args: [message.c_str()] + # homeassistant.action with on_success/on_error stores the trigger args + # until the action response arrives, so the codegen must fall back to + # owning std::string args (StringRef would dangle once the receive + # buffer is reused). + - action: response_args + variables: + message: string + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda return message; + on_success: + - logger.log: + format: "sent %s" + args: [message.c_str()] + on_error: + - logger.log: + format: "failed (%s): %s" + args: [error.c_str(), message.c_str()] 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..1396c18e3b 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,14 @@ def test_all_predefined_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + + # 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 +173,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 +208,14 @@ def test_individual_models( if not model.get_default(CONF_CS_PIN): config[CONF_CS_PIN] = 5 + # Dual-CS models (e.g. T133A01) require a second chip-select pin + if model.manages_cs and not model.get_default("cs1_pin"): + config["cs1_pin"] = 4 + + # 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 +363,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_idf.yaml b/tests/component_tests/esp32/config/flash_mode_idf.yaml index 7c7f50a439..d12d4a734b 100644 --- a/tests/component_tests/esp32/config/flash_mode_idf.yaml +++ b/tests/component_tests/esp32/config/flash_mode_idf.yaml @@ -5,5 +5,6 @@ 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/config/nvs_encryption_s3.yaml b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml new file mode 100644 index 0000000000..371f2e28ca --- /dev/null +++ b/tests/component_tests/esp32/config/nvs_encryption_s3.yaml @@ -0,0 +1,10 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 diff --git a/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml new file mode 100644 index 0000000000..450e1bb345 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_disabled_gpio34.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + disabled: true + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_octal_gpio34.yaml b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml new file mode 100644 index 0000000000..b385057d79 --- /dev/null +++ b/tests/component_tests/esp32/config/psram_octal_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/config/psram_quad_gpio34.yaml b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml new file mode 100644 index 0000000000..9612edb75b --- /dev/null +++ b/tests/component_tests/esp32/config/psram_quad_gpio34.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: quad + +binary_sensor: + - platform: gpio + pin: GPIO34 + name: test diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index a8b5720a80..fdca70bf2c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -2,14 +2,26 @@ 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, + _ota_downgrade_protection_errors, + _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 +76,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"), [ @@ -131,6 +175,29 @@ def test_esp32_config( r"'ignore_efuse_mac_crc' is not supported on ESP32S3 @ data\['framework'\]\['advanced'\]\['ignore_efuse_mac_crc'\]", id="ignore_efuse_mac_crc_only_on_esp32", ), + pytest.param( + { + "variant": "esp32", + "board": "esp32dev", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 0}}, + }, + }, + r"NVS encryption \(HMAC scheme\) is not supported on ESP32 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]", + id="nvs_encryption_unsupported_on_esp32", + ), + pytest.param( + { + "variant": "esp32s3", + "framework": { + "type": "esp-idf", + "advanced": {"nvs_encryption": {"key_id": 6}}, + }, + }, + r"value must be at most 5 .* @ data\['framework'\]\['advanced'\]\['nvs_encryption'\]\['key_id'\]", + id="nvs_encryption_key_id_out_of_range", + ), ], ) def test_esp32_configuration_errors( @@ -170,6 +237,47 @@ def test_execute_from_psram_p4_sdkconfig( assert "CONFIG_SPIRAM_RODATA" not in sdkconfig +def test_nvs_encryption_sdkconfig( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that nvs_encryption sets the HMAC scheme sdkconfig options.""" + generate_main(component_config_path("nvs_encryption_s3.yaml")) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig.get("CONFIG_NVS_ENCRYPTION") is True + assert sdkconfig.get("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC") is True + assert sdkconfig.get("CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID") == 0 + # The permanent/irreversible eFuse burn is warned about at config time. + assert "PERMANENT and IRREVERSIBLE" in caplog.text + + +@pytest.mark.parametrize( + ("fixture", "expect_warning"), + [ + ("psram_quad_gpio34.yaml", False), + ("psram_octal_gpio34.yaml", True), + ("psram_octal_disabled_gpio34.yaml", False), + ], +) +def test_s3_psram_pin_warning_only_for_octal( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, + fixture: str, + expect_warning: bool, +) -> None: + """GPIO33-37 are only used by the PSRAM interface in octal mode. + + Using such a pin must only warn when octal PSRAM is configured; on quad + PSRAM the pins are free and warning would be a false positive (#16857). + """ + with caplog.at_level("WARNING"): + generate_main(component_config_path(fixture)) + warned = "GPIO34 is used by the PSRAM interface in octal mode" in caplog.text + assert warned == expect_warning + + def test_ignore_pin_validation_error_on_clean_pin_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture, @@ -311,3 +419,324 @@ def test_flash_mode_unset_leaves_defaults( 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 + + +def test_esp32_build_internals_are_yaml_only() -> None: + """ESP32 raw framework / build inputs are ``YAML_ONLY``. + + The framework block's PlatformIO package pins, raw ESP-IDF + sdkconfig options, the low-level ``advanced`` block, extra IDF + component sources, plus the partition table and toolchain override + on the main schema are build internals — never UI form fields. + User-facing choices (framework type/version, board, variant, …) + stay on the main form. + """ + from esphome.components.esp32 import CONFIG_SCHEMA, FRAMEWORK_SCHEMA + + fw_markers = {str(k): k for k in FRAMEWORK_SCHEMA.schema} + for field in ( + "release", + "source", + "platform_version", + "sdkconfig_options", + "advanced", + "components", + ): + assert fw_markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Framework type/version remain user-facing. + assert fw_markers["type"].visibility is None + assert fw_markers["version"].visibility is None + + main_markers = {str(k): k for k in CONFIG_SCHEMA.validators[0].schema} + assert main_markers["partitions"].visibility is cv.Visibility.YAML_ONLY + # toolchain is a real but rarely-touched override -> advanced disclosure. + assert main_markers["toolchain"].visibility is cv.Visibility.ADVANCED + assert main_markers["board"].visibility is None + assert main_markers["flash_size"].visibility is None + + +def test_downgrade_protection_passes_with_numeric_version_and_signing() -> None: + assert _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_accepts_calendar_version() -> None: + assert _ota_downgrade_protection_errors("2024.12.0", signed_ota_enabled=True) == [] + + +def test_downgrade_protection_requires_project_version() -> None: + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=True) + assert len(errs) == 1 + assert "version" in str(errs[0]) + + +def test_downgrade_protection_rejects_non_numeric_version() -> None: + errs = _ota_downgrade_protection_errors("1.0-beta", signed_ota_enabled=True) + assert len(errs) == 1 + assert "dotted-numeric" in str(errs[0]) + + +def test_downgrade_protection_requires_signed_ota() -> None: + errs = _ota_downgrade_protection_errors("1.2.3", signed_ota_enabled=False) + assert len(errs) == 1 + assert "signed_ota_verification" in str(errs[0]) + + +def test_downgrade_protection_reports_all_unmet_requirements() -> None: + # No project version and no signing -> two distinct errors. + errs = _ota_downgrade_protection_errors(None, signed_ota_enabled=False) + assert len(errs) == 2 + + +@pytest.mark.parametrize( + "config", + [ + # V2 schemes: signing key (sign during build) or no key at all + # (external signing; the public key travels in the signature block). + {"signing_scheme": "rsa3072", "signing_key": "key.pem"}, + {"signing_scheme": "rsa3072"}, + {"signing_scheme": "ecdsa256", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa256"}, + # V1 ECDSA: exactly one of signing key / verification key. + {"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"}, + {"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"}, + ], +) +def test_signed_ota_keys_valid_combinations(config: dict) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + assert _validate_signed_ota_keys(config) is config + + +@pytest.mark.parametrize("value", [None, {}]) +def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -> None: + """A bare `signed_ota_verification:` block is valid: the default V2 + scheme embeds the public key in the signature block, so verifying + externally-signed binaries needs no keys in the config.""" + from esphome.components.esp32 import _validate_signed_ota_verification + + config = _validate_signed_ota_verification(value) + assert config == {"signing_scheme": "rsa3072"} + + +@pytest.mark.parametrize( + ("config", "match"), + [ + # A verification key is meaningless with the V2 schemes -- the public + # key is embedded in each image's signature block. + ( + {"signing_scheme": "rsa3072", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + ( + {"signing_scheme": "ecdsa256", "verification_key": "key.bin"}, + "only used with signing scheme 'ecdsa_v1'", + ), + # V1 ECDSA needs a key either way. + ( + {"signing_scheme": "ecdsa_v1"}, + "Signing scheme 'ecdsa_v1' requires either", + ), + # Never both keys at once. + ( + { + "signing_scheme": "rsa3072", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ( + { + "signing_scheme": "ecdsa_v1", + "signing_key": "key.pem", + "verification_key": "key.bin", + }, + "not both", + ), + ], +) +def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None: + from esphome.components.esp32 import _validate_signed_ota_keys + + with pytest.raises(cv.Invalid, match=match): + _validate_signed_ota_keys(config) diff --git a/esphome/dashboard/util/__init__.py b/tests/component_tests/gsl3670/__init__.py similarity index 100% rename from esphome/dashboard/util/__init__.py rename to tests/component_tests/gsl3670/__init__.py diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py new file mode 100644 index 0000000000..3778cf8aa5 --- /dev/null +++ b/tests/component_tests/gsl3670/test_init.py @@ -0,0 +1,260 @@ +"""Tests for the gsl3670 touchscreen configuration validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.const import ( + CONF_CALIBRATION, + CONF_INTERRUPT_PIN, + CONF_MODEL, + CONF_RESET_PIN, + CONF_TRANSFORM, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +VALID_URL = "https://example.com/fw.bin" + + +def _make_firmware(blocks: int = 2) -> bytes: + """Build a structurally valid firmware blob with ``blocks`` blocks. + + Each block is ``_FW_BLK_SIZE`` bytes: a 4-byte header (page address <= 0xEF + followed by the 1/2/3 marker bytes) and a 128-byte payload. + """ + out = bytearray() + for i in range(blocks): + out += bytes([i, 1, 2, 3]) + bytes(gsl._FW_BLK_SIZE - 4) + return bytes(out) + + +def _write_firmware(tmp_path: Path, data: bytes | None = None) -> Path: + """Write firmware bytes to a temp file and return its path.""" + path = tmp_path / "fw.bin" + path.write_bytes(_make_firmware() if data is None else data) + return path + + +# --------------------------------------------------------------------------- +# _validate_firmware_data - blob structure +# --------------------------------------------------------------------------- + + +def test_validate_firmware_data_accepts_valid_blob() -> None: + """A correctly structured blob passes validation.""" + gsl._validate_firmware_data(_make_firmware(3), "test") + + +@pytest.mark.parametrize("length", [0, gsl._FW_BLK_SIZE - 1, gsl._FW_BLK_SIZE + 1]) +def test_validate_firmware_data_rejects_bad_length(length: int) -> None: + """The blob length must be a non-zero multiple of the block size.""" + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware_data(bytes(length), "test") + + +@pytest.mark.parametrize( + "index,value", + [ + (0, 0xF0), # page address must be <= 0xEF + (1, 0x00), # marker byte must be 1 + (2, 0x00), # marker byte must be 2 + (3, 0x00), # marker byte must be 3 + ], +) +def test_validate_firmware_data_rejects_corrupted_header( + index: int, value: int +) -> None: + """A block whose header bytes are wrong is reported as corrupted.""" + data = bytearray(_make_firmware(2)) + # Corrupt the header of the second block. + data[gsl._FW_BLK_SIZE + index] = value + with pytest.raises(cv.Invalid, match="Corrupted firmware at block 1"): + gsl._validate_firmware_data(bytes(data), "test") + + +# --------------------------------------------------------------------------- +# _cache_path / firmware_path +# --------------------------------------------------------------------------- + + +def test_cache_path_is_deterministic_per_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The cache path is derived from (and stable for) the URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + first = gsl._cache_path(VALID_URL) + assert first == gsl._cache_path(VALID_URL) + assert first != gsl._cache_path("https://example.com/other.bin") + assert first.parent == tmp_path + + +def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: + """A ``file`` source is returned as-is, without consulting the cache.""" + path = _write_firmware(tmp_path) + assert gsl.firmware_path({"file": path}) == path + + +def test_firmware_path_uses_cache_for_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A ``url`` source resolves to the cache path for that URL.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) + + +# --------------------------------------------------------------------------- +# _validate_firmware / FIRMWARE_SCHEMA +# --------------------------------------------------------------------------- + + +def test_firmware_requires_exactly_one_source(tmp_path: Path) -> None: + """Supplying both, or neither, of url/file is an error.""" + path = _write_firmware(tmp_path) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({"url": VALID_URL, "file": path}) + with pytest.raises(cv.Invalid, match="Exactly one"): + gsl._validate_firmware({}) + + +def test_firmware_file_valid(tmp_path: Path) -> None: + """A valid firmware file passes the full FIRMWARE_SCHEMA.""" + path = _write_firmware(tmp_path) + result = gsl.FIRMWARE_SCHEMA({"file": str(path)}) + assert result["file"] == path + + +def test_firmware_file_corrupt_rejected(tmp_path: Path) -> None: + """A file whose contents fail the structural check is rejected.""" + path = _write_firmware(tmp_path, data=b"\x00" * (gsl._FW_BLK_SIZE * 2)) + with pytest.raises(cv.Invalid, match="Corrupted firmware"): + gsl._validate_firmware({"file": path}) + + +def test_firmware_url_downloads_and_validates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A url source downloads the content and validates its structure.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} + + +def test_firmware_url_sha256_mismatch_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A configured SHA-256 that does not match the download is rejected.""" + data = _make_firmware() + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) + with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): + gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) + + +def test_firmware_url_invalid_structure_rejected( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Downloaded content that is not a valid blob is rejected.""" + monkeypatch.setattr( + gsl.external_files, "compute_local_file_dir", lambda _: tmp_path + ) + monkeypatch.setattr( + gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" + ) + with pytest.raises(cv.Invalid, match="length is incorrect"): + gsl._validate_firmware({"url": VALID_URL}) + + +# --------------------------------------------------------------------------- +# CONFIG_SCHEMA +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _esp32_core(set_core_config: SetCoreConfigCallable) -> None: + """Configure the core as an ESP32 target for the schema tests.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + +def test_config_custom_model_minimal(tmp_path: Path) -> None: + """The CUSTOM model validates with an explicit firmware file and pins.""" + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "custom", + "interrupt_pin": 16, + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "CUSTOM" + assert "id" in result + # The CUSTOM model supplies no transform/calibration defaults. + assert CONF_TRANSFORM not in result + assert CONF_CALIBRATION not in result + + +def test_config_custom_model_requires_firmware() -> None: + """The firmware option is required for the CUSTOM model (no default).""" + with pytest.raises(cv.Invalid, match=r"required key not provided.*firmware"): + gsl.CONFIG_SCHEMA({"model": "custom", "interrupt_pin": 16, "reset_pin": 4}) + + +def test_config_invalid_model_rejected() -> None: + """An unknown model name is rejected.""" + with pytest.raises(cv.Invalid, match="model"): + gsl.CONFIG_SCHEMA({"model": "nonexistent"}) + + +def test_config_seeed_model_applies_defaults(tmp_path: Path) -> None: + """The SEEED model populates transform and calibration defaults. + + ``reset_pin`` is overridden with a plain GPIO so the test does not depend on + the model's default IO-expander pin. + """ + fw = _write_firmware(tmp_path) + result = gsl.CONFIG_SCHEMA( + { + "model": "seeed-reterminal-d1001", + "reset_pin": 4, + "firmware": {"file": str(fw)}, + } + ) + assert result[CONF_MODEL] == "SEEED-RETERMINAL-D1001" + # Transform defaults from the model. + assert result[CONF_TRANSFORM] == { + "swap_xy": True, + "mirror_x": True, + "mirror_y": True, + } + # Calibration defaults from the model. + assert result[CONF_CALIBRATION]["x_min"] == 20 + assert result[CONF_CALIBRATION]["x_max"] == 872 + assert result[CONF_CALIBRATION]["y_min"] == 20 + assert result[CONF_CALIBRATION]["y_max"] == 1644 + # The interrupt pin default (16) is applied without being specified. + assert CONF_INTERRUPT_PIN in result + assert CONF_RESET_PIN in result + + +def test_config_rejects_non_dict() -> None: + """A non-dict configuration is rejected.""" + with pytest.raises(cv.Invalid, match="expected a dictionary"): + gsl.CONFIG_SCHEMA("not a dict") diff --git a/tests/component_tests/image/config/image_test.yaml b/tests/component_tests/image/config/image_test.yaml index c34e0993a5..31c29de21b 100644 --- a/tests/component_tests/image/config/image_test.yaml +++ b/tests/component_tests/image/config/image_test.yaml @@ -4,6 +4,9 @@ esphome: esp32: board: esp32s3box +psram: + mode: octal + image: defaults: type: rgb565 diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index f7f60a1f4d..78462463b1 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable +import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch @@ -11,28 +12,36 @@ from PIL import Image as PILImage import pytest from esphome import config_validation as cv +from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.file import image as file_image +from esphome.components.file.image import validate_image_final, write_image from esphome.components.image import ( CONF_ALPHA_CHANNEL, CONF_INVERT_ALPHA, CONF_OPAQUE, CONF_TRANSPARENCY, - CONFIG_SCHEMA, + PLATFORM_FILE, + _flatten_legacy_image_config, + _is_legacy_image_format, + _is_new_image_format, + _migrate_legacy_image_config, get_all_image_metadata, get_image_metadata, - write_image, ) -from esphome.const import CONF_DITHER, CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_TYPE +from esphome.const import ( + CONF_DITHER, + CONF_FILE, + CONF_ID, + CONF_PLATFORM, + CONF_RAW_DATA_ID, + CONF_TYPE, +) from esphome.core import CORE @pytest.mark.parametrize( ("config", "error_match"), [ - pytest.param( - "a string", - "Badly formed image configuration, expected a list or a dictionary", - id="invalid_string_config", - ), pytest.param( {"id": "image_id", "type": "rgb565"}, r"required key not provided @ data\['file'\]", @@ -43,6 +52,11 @@ from esphome.core import CORE r"required key not provided @ data\['id'\]", id="missing_id", ), + pytest.param( + {"id": "image_id", "file": "image.png"}, + r"required key not provided @ data\['type'\]", + id="missing_type", + ), pytest.param( {"id": "mdi_id", "file": "mdi:weather-##", "type": "rgb565"}, "Could not parse mdi icon name", @@ -84,155 +98,301 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), - pytest.param( - {"defaults": {}, "images": [{"id": "image_id", "file": "image.png"}]}, - "Type is required either in the image config or in the defaults", - id="missing_type_in_defaults", - ), ], ) -def test_image_configuration_errors( +def test_file_platform_configuration_errors( config: Any, error_match: str, ) -> None: - """Test detection of invalid configuration.""" + """Invalid single-entry ``platform: file`` configs are rejected.""" with pytest.raises(cv.Invalid, match=error_match): - CONFIG_SCHEMA(config) + file_image.CONFIG_SCHEMA(config) + + +def test_file_platform_configuration_success() -> None: + """A fully-specified ``platform: file`` entry validates and keeps its keys.""" + result = file_image.CONFIG_SCHEMA( + { + "id": "image_id", + "file": "image.png", + "type": "rgb565", + "transparency": "chroma_key", + "byte_order": "little_endian", + "dither": "FloydSteinberg", + "resize": "100x100", + "invert_alpha": False, + } + ) + for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): + assert key in result, f"Missing key {key} in validated image configuration" + + +# --------------------------------------------------------------------------- +# Legacy `image:` config migration -- REMOVE these tests after 2027.1.0 together +# with the migration shim in esphome/components/image/__init__.py. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], True, id="new_platform_list" + ), + pytest.param([], True, id="empty_list"), + pytest.param([{"id": "a", "file": "x.png"}], False, id="legacy_bare_list"), + pytest.param([{CONF_PLATFORM: "file"}, {"id": "a"}], False, id="mixed_list"), + pytest.param( + [{CONF_PLATFORM: "file"}, "not-a-dict"], False, id="non_dict_entry" + ), + pytest.param({"defaults": {}}, False, id="legacy_dict"), + ], +) +def test_is_new_image_format(config: object, expected: bool) -> None: + assert _is_new_image_format(config) is expected + + +def test_flatten_bare_list_filters_non_dicts() -> None: + out = _flatten_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}, "not-a-dict"] + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_non_dict_non_list_yields_nothing() -> None: + assert _flatten_legacy_image_config("a string") == [] + + +def test_flatten_single_dict_with_id() -> None: + config = {"id": "a", "file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_single_dict_with_file_only() -> None: + config = {"file": "x.png", "type": "binary"} + assert _flatten_legacy_image_config(config) == [config] + + +def test_flatten_defaults_images_list() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565", "byte_order": "little_endian"}, + "images": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "byte_order": "little_endian", + } + ] + + +def test_flatten_defaults_images_single_dict() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "rgb565"}, + "images": {"id": "a", "file": "x.png"}, + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "rgb565"}] + + +def test_flatten_type_grouped_list() -> None: + out = _flatten_legacy_image_config({"binary": [{"id": "a", "file": "x.png"}]}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_transparency_list() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_transparency_single_dict() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": {"id": "a", "file": "x.png"}}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_flatten_type_grouped_dict_without_transparency() -> None: + out = _flatten_legacy_image_config({"binary": {"id": "a", "file": "x.png"}}) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_drops_byte_order_for_non_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "binary": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + assert CONF_BYTE_ORDER not in out[0] + + +def test_flatten_keeps_byte_order_for_endian_type() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"byte_order": "little_endian"}, + "rgb565": [{"id": "a", "file": "x.png"}], + } + ) + assert out[0][CONF_BYTE_ORDER] == "little_endian" + + +def test_flatten_skips_meta_and_unknown_keys() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [], + "not_a_type": [{"id": "a", "file": "x.png"}], + } + ) + assert out == [] + + +def test_flatten_images_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + { + "defaults": {"type": "binary"}, + "images": [{"id": "a", "file": "x.png"}, "not-a-dict"], + } + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_list_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"binary": [{"id": "a", "file": "x.png"}, "not-a-dict"]} + ) + assert out == [{"id": "a", "file": "x.png", "type": "binary"}] + + +def test_flatten_type_grouped_scalar_value_is_ignored() -> None: + # A known type key whose value is neither a list nor a dict yields nothing. + assert _flatten_legacy_image_config({"binary": "not-a-list-or-dict"}) == [] + + +def test_flatten_type_grouped_transparency_skips_non_dict_entries() -> None: + out = _flatten_legacy_image_config( + {"rgb565": {"alpha_channel": [{"id": "a", "file": "x.png"}, "not-a-dict"]}} + ) + assert out == [ + { + "id": "a", + "file": "x.png", + "type": "rgb565", + "transparency": "alpha_channel", + } + ] + + +def test_migrate_returns_none_for_new_format() -> None: + assert _migrate_legacy_image_config([{CONF_PLATFORM: "file", "id": "a"}]) is None + + +def test_migrate_legacy_warns_and_prepends_platform( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = _migrate_legacy_image_config( + [{"id": "a", "file": "x.png", "type": "binary"}] + ) + assert out == [ + {CONF_PLATFORM: PLATFORM_FILE, "id": "a", "file": "x.png", "type": "binary"} + ] + assert "deprecated" in caplog.text + assert f"platform: {PLATFORM_FILE}" in caplog.text + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + # Recognised legacy shapes -> migrate. + pytest.param([{"id": "a", "file": "x.png"}], True, id="bare_list_of_dicts"), + pytest.param({"id": "a", "file": "x.png"}, True, id="single_image_dict"), + pytest.param({"file": "x.png"}, True, id="single_dict_file_only"), + pytest.param({"defaults": {}, "images": []}, True, id="defaults_images"), + pytest.param({"rgb565": [{"id": "a"}]}, True, id="type_grouped"), + # Shapes the legacy schema never accepted -> not migrated. + pytest.param([], False, id="empty_list"), + pytest.param(["bad"], False, id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], False, id="list_mixed_dict_and_non_dict"), + pytest.param( + [{CONF_PLATFORM: "file", "id": "a"}], False, id="already_platform_tagged" + ), + pytest.param({"foo": 1}, False, id="dict_unknown_keys"), + pytest.param("a string", False, id="scalar"), + ], +) +def test_is_legacy_image_format(config: object, expected: bool) -> None: + assert _is_legacy_image_format(config) is expected @pytest.mark.parametrize( "config", [ - pytest.param( - { - "id": "image_id", - "file": "image.png", - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - id="single_image_all_options", - ), - pytest.param( - [ - { - "id": "image_id", - "file": "image.png", - "type": "binary", - } - ], - id="list_of_images", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "images": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - id="images_with_defaults", - ), - pytest.param( - { - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - } - ], - }, - id="type_based_organization", - ), - pytest.param( - { - "defaults": { - "type": "binary", - "transparency": "chroma_key", - "byte_order": "little_endian", - "dither": "FloydSteinberg", - "resize": "100x100", - "invert_alpha": False, - }, - "rgb565": { - "alpha_channel": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "alpha_channel", - "dither": "none", - } - ] - }, - "binary": [ - { - "id": "image_id", - "file": "image.png", - "transparency": "opaque", - } - ], - }, - id="type_based_with_defaults", - ), - pytest.param( - { - "defaults": { - "type": "rgb565", - "transparency": "alpha_channel", - }, - "binary": { - "opaque": [ - { - "id": "image_id", - "file": "image.png", - } - ], - }, - }, - id="binary_with_defaults", - ), + pytest.param(["bad"], id="list_with_non_dict"), + pytest.param([{"id": "a"}, "bad"], id="list_mixed"), + pytest.param({"foo": 1}, id="dict_unknown_keys"), ], ) -def test_image_configuration_success( - config: dict[str, Any] | list[dict[str, Any]], +def test_migrate_returns_none_for_invalid_legacy_shapes( + config: object, caplog: pytest.LogCaptureFixture ) -> None: - """Test successful configuration validation.""" - result = CONFIG_SCHEMA(config) - # All valid configurations should return a list of images - assert isinstance(result, list) - for key in (CONF_TYPE, CONF_ID, CONF_TRANSPARENCY, CONF_RAW_DATA_ID): - assert all(key in x for x in result), ( - f"Missing key {key} in image configuration" + """Unrecognised shapes are not migrated (and emit no warning) so normal + platform validation surfaces a proper error instead of silently dropping + the offending input.""" + with caplog.at_level(logging.WARNING): + assert _migrate_legacy_image_config(config) is None + assert "deprecated" not in caplog.text + + +# --------------------------- end legacy migration -------------------------- + + +def test_validate_image_final_defaults_to_little_endian() -> None: + out = validate_image_final({CONF_FILE: "x.png"}) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + + +def test_validate_image_final_keeps_little_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final( + {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"} ) + assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN" + assert "big-endian" not in caplog.text + + +def test_validate_image_final_warns_on_big_endian( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}) + assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN" + assert "big-endian" in caplog.text def test_image_generation( @@ -369,7 +529,7 @@ def test_get_all_image_metadata_empty() -> None: @pytest.fixture def mock_progmem_array(): """Mock progmem_array to avoid needing a proper ID object in tests.""" - with patch("esphome.components.image.cg.progmem_array") as mock_progmem: + with patch("esphome.components.file.image.cg.progmem_array") as mock_progmem: mock_progmem.return_value = MagicMock() yield mock_progmem diff --git a/tests/component_tests/lvgl/config/layout_update_test.yaml b/tests/component_tests/lvgl/config/layout_update_test.yaml new file mode 100644 index 0000000000..84765a60cf --- /dev/null +++ b/tests/component_tests/lvgl/config/layout_update_test.yaml @@ -0,0 +1,92 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + id: lvgl_id + displays: tft_display + pages: + - id: main_page + widgets: + # A flex container whose layout options are changed at runtime. + - obj: + id: flex_box + layout: + type: flex + flex_flow: row + widgets: + - label: + text: a + - label: + text: b + + # A grid container whose alignment options are changed at runtime. + # The grid structure (rows/columns) is fixed here at creation. + - obj: + id: grid_box + layout: + type: grid + grid_rows: [content, content] + grid_columns: [fr(1), fr(1)] + widgets: + - label: + text: c + - label: + text: d + + # Button hosting all of the update actions under test. + - button: + id: btn_actions + on_click: + # Update flex container options (type unchanged). + - lvgl.widget.update: + id: flex_box + layout: + flex_flow: column + flex_align_main: center + flex_align_cross: end + pad_row: 7px + # Update grid container alignment options (structure unchanged). + - lvgl.widget.update: + id: grid_box + layout: + grid_column_align: space_between + grid_row_align: center + # Top-level layout applies to the active screen. + - lvgl.update: + layout: + flex_flow: column + pad_column: 5px + # Layout applied to the top display layer. + - lvgl.update: + top_layer: + layout: + flex_flow: row + # Styling applied to the bottom display layer (exercises the + # layers code path that previously generated no code). + - lvgl.update: + bottom_layer: + bg_color: 0x123456 diff --git a/tests/component_tests/lvgl/config/not_paused.yaml b/tests/component_tests/lvgl/config/not_paused.yaml new file mode 100644 index 0000000000..1dfe8f4ee9 --- /dev/null +++ b/tests/component_tests/lvgl/config/not_paused.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-not-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/config/paused.yaml b/tests/component_tests/lvgl/config/paused.yaml new file mode 100644 index 0000000000..ea747ec75b --- /dev/null +++ b/tests/component_tests/lvgl/config/paused.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-paused + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + paused: true + widgets: + - obj: + id: root_obj diff --git a/tests/component_tests/lvgl/test_animation.py b/tests/component_tests/lvgl/test_animation.py new file mode 100644 index 0000000000..1a2cde632c --- /dev/null +++ b/tests/component_tests/lvgl/test_animation.py @@ -0,0 +1,201 @@ +"""Tests for the LVGL animation schema and configuration validation.""" + +from __future__ import annotations + +import pytest +from voluptuous import Invalid, MultipleInvalid + +from esphome.components.lvgl.animation import ( + ANIMABLE_STYLES, + ANIMATION_SCHEMA, + TIMING_SCHEMA, + from_to, + literal_color, +) +from esphome.components.lvgl.defines import LValidator +from esphome.core import Lambda + + +def _animation(**overrides) -> dict: + """A minimal valid animation config, with optional overrides applied.""" + config = { + "id": "anim_id", + "widgets": [{"id": "widget_id", "x": {"from": 0, "to": 100}}], + } + config.update(overrides) + return config + + +# --------------------------------------------------------------------------- +# Animatable property set +# --------------------------------------------------------------------------- + + +class TestAnimableStyles: + def test_all_entries_are_animatable_validators(self) -> None: + """Every animatable style must be an LValidator marked animatable.""" + assert ANIMABLE_STYLES + assert all( + isinstance(v, LValidator) and v.animatable for v in ANIMABLE_STYLES.values() + ) + + def test_known_animatable_present(self) -> None: + for prop in ("x", "y", "opa", "bg_color", "transform_rotation"): + assert prop in ANIMABLE_STYLES + + def test_non_animatable_absent(self) -> None: + # width/height set size but are not animatable; layout/padding never are. + for prop in ("width", "height", "radius", "pad_all", "align"): + assert prop not in ANIMABLE_STYLES + + +# --------------------------------------------------------------------------- +# Animation schema +# --------------------------------------------------------------------------- + + +class TestAnimationSchema: + def test_defaults(self) -> None: + config = ANIMATION_SCHEMA(_animation()) + assert config["duration"].total_milliseconds == 5000 + assert config["start_delay"].total_milliseconds == 0 + assert config["auto_start"] is False + assert config["loop"] is False + assert config["timing"] == [] + + def test_values_preserved(self) -> None: + config = ANIMATION_SCHEMA( + _animation(duration="2s", start_delay="250ms", auto_start=True, loop=True) + ) + assert config["duration"].total_milliseconds == 2000 + assert config["start_delay"].total_milliseconds == 250 + assert config["auto_start"] is True + assert config["loop"] is True + + def test_id_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"widgets": [{"id": "widget_id"}]}) + + def test_widgets_required(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA({"id": "anim_id"}) + + def test_multiple_properties_and_widgets(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "x": {"from": 0, "to": 100}, + "opa": {"from": "0%", "to": "100%"}, + }, + {"id": "w2", "y": {"from": 10, "to": 50}}, + ] + ) + ) + assert len(config["widgets"]) == 2 + + def test_unknown_property_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + ANIMATION_SCHEMA( + _animation(widgets=[{"id": "w1", "not_a_style": {"from": 0, "to": 1}}]) + ) + + +class TestAnimatedColorLiteral: + """A color animated via from/to must be a literal, not a lambda.""" + + def test_color_lambda_rejected_directly(self) -> None: + with pytest.raises(Invalid, match="lambda"): + literal_color(Lambda("return lv_color_hex(0xFF0000);")) + + def test_color_literal_accepted_directly(self) -> None: + # A literal color value validates without error. + literal_color(0xFF0000) + + def test_color_lambda_rejected_in_animation(self) -> None: + with pytest.raises((Invalid, MultipleInvalid), match="lambda"): + ANIMATION_SCHEMA( + _animation( + widgets=[ + { + "id": "w1", + "text_color": { + "from": Lambda("return lv_color_hex(0xFF0000);"), + "to": 0x00FF00, + }, + } + ] + ) + ) + + def test_color_literals_accepted_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "text_color": {"from": 0xFF0000, "to": 0x00FF00}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + def test_non_color_property_allows_lambda(self) -> None: + # Only colors are restricted; numeric properties may use lambdas. + config = ANIMATION_SCHEMA( + _animation( + widgets=[{"id": "w1", "x": {"from": Lambda("return 5;"), "to": 100}}] + ) + ) + assert config["widgets"][0]["id"].id == "w1" + + +class TestFromTo: + def test_requires_both(self) -> None: + validator = from_to(lambda value: value) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"from": 1}) + with pytest.raises((Invalid, MultipleInvalid)): + validator({"to": 1}) + + def test_accepts_both(self) -> None: + validator = from_to(lambda value: value) + assert validator({"from": 1, "to": 2}) == {"from": 1, "to": 2} + + +# --------------------------------------------------------------------------- +# Timing schema +# --------------------------------------------------------------------------- + + +class TestTimingSchema: + def test_round_trip_string(self) -> None: + assert TIMING_SCHEMA("round_trip")["type"] == "round_trip" + + def test_ease_in_out_default_weight(self) -> None: + result = TIMING_SCHEMA("ease_in_out") + assert result["type"] == "ease_in_out" + assert result["weight"] == pytest.approx(2.0) + + def test_ease_in_out_custom_weight(self) -> None: + result = TIMING_SCHEMA({"type": "ease_in_out", "weight": 3}) + assert result["weight"] == pytest.approx(3.0) + + def test_gravity_defaults(self) -> None: + result = TIMING_SCHEMA("gravity") + assert result["type"] == "gravity" + assert result["bounce"] == pytest.approx(0.5) + assert result["acceleration"] == pytest.approx(0.5) + + def test_gravity_custom(self) -> None: + result = TIMING_SCHEMA({"type": "gravity", "bounce": 0.3, "acceleration": 0.8}) + assert result["bounce"] == pytest.approx(0.3) + assert result["acceleration"] == pytest.approx(0.8) + + def test_unknown_type_rejected(self) -> None: + with pytest.raises((Invalid, MultipleInvalid)): + TIMING_SCHEMA({"type": "not_a_timing"}) + + def test_timing_list_in_animation(self) -> None: + config = ANIMATION_SCHEMA( + _animation(timing=["round_trip", {"type": "gravity", "bounce": 0.3}]) + ) + types = [t["type"] for t in config["timing"]] + assert types == ["round_trip", "gravity"] diff --git a/tests/component_tests/lvgl/test_layout_update.py b/tests/component_tests/lvgl/test_layout_update.py new file mode 100644 index 0000000000..b9730df379 --- /dev/null +++ b/tests/component_tests/lvgl/test_layout_update.py @@ -0,0 +1,208 @@ +"""Tests for updating LVGL layout options via the update actions. + +The ``lvgl.update`` and ``lvgl.widget.update`` (and per-widget +``lvgl..update``) actions can change a container's layout *options* at +runtime. The layout ``type`` and the grid ``grid_rows``/``grid_columns`` +structure are fixed at widget creation (they determine the cells/options +available to child widgets), so only the simple style options - those applied +via ``lv_obj_set_style_...`` calls - may be changed. + +These tests cover both the ``layout_validator`` (schema/normalisation) and the +generated C++ for each target: a widget, the active screen (top-level +``lvgl.update``) and the display layers. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from voluptuous import Invalid + +from esphome.__main__ import generate_cpp_contents +from esphome.components.lvgl.defines import TYPE_FLEX, TYPE_GRID, get_lv_uses +from esphome.components.lvgl.layout import layout_validator +from esphome.config import read_config +from esphome.core import CORE + +# --------------------------------------------------------------------------- +# layout_validator - schema and normalisation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ({"flex_flow": "row"}, {"flex_flow": "LV_FLEX_FLOW_ROW"}), + ({"flex_align_main": "center"}, {"flex_align_main": "LV_FLEX_ALIGN_CENTER"}), + ({"flex_align_cross": "end"}, {"flex_align_cross": "LV_FLEX_ALIGN_END"}), + ( + {"grid_column_align": "space_between"}, + {"grid_column_align": "LV_GRID_ALIGN_SPACE_BETWEEN"}, + ), + ({"grid_row_align": "center"}, {"grid_row_align": "LV_GRID_ALIGN_CENTER"}), + ({"pad_row": "7px"}, {"pad_row": 7}), + ({"pad_column": "5px"}, {"pad_column": 5}), + ], +) +def test_layout_validator_normalises_options(value: dict, expected: dict) -> None: + """Each supported option is accepted and normalised to its LVGL form.""" + assert layout_validator(value) == expected + + +def test_layout_validator_accepts_multiple_options() -> None: + """Several options may be combined in one update.""" + result = layout_validator( + {"flex_flow": "column", "flex_align_main": "center", "pad_row": "4px"} + ) + assert result == { + "flex_flow": "LV_FLEX_FLOW_COLUMN", + "flex_align_main": "LV_FLEX_ALIGN_CENTER", + "pad_row": 4, + } + + +@pytest.mark.parametrize( + "value", + [ + {"type": "flex"}, + {"type": "grid", "grid_column_align": "center"}, + {"grid_rows": 3}, + {"grid_columns": ["fr(1)"]}, + {"grid_rows": [1, 2], "flex_flow": "row"}, + ], +) +def test_layout_validator_rejects_structural_keys(value: dict) -> None: + """The layout type and grid structure are fixed at creation and must not + be changeable via an update action.""" + with pytest.raises(Invalid, match="extra keys not allowed"): + layout_validator(value) + + +def test_layout_validator_rejects_empty() -> None: + """An update must specify at least one layout option.""" + with pytest.raises(Invalid, match="at least one layout option"): + layout_validator({}) + + +def test_layout_validator_registers_flex_use() -> None: + """Validating a flex option registers the flex feature so LV_USE_FLEX is + emitted even when the option is set solely via an update action.""" + layout_validator({"flex_flow": "row"}) + assert TYPE_FLEX in get_lv_uses() + + +def test_layout_validator_registers_grid_use() -> None: + """Validating a grid option registers the grid feature.""" + layout_validator({"grid_column_align": "center"}) + assert TYPE_GRID in get_lv_uses() + + +def test_pad_only_update_registers_no_layout_use() -> None: + """Padding options belong to both layout types, so they alone do not force + either feature on.""" + layout_validator({"pad_row": "4px"}) + uses = get_lv_uses() + assert TYPE_FLEX not in uses + assert TYPE_GRID not in uses + + +# --------------------------------------------------------------------------- +# Generated C++ for the update actions +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def main_cpp(request: pytest.FixtureRequest) -> str: + """Generate the C++ output for the shared layout-update YAML config once + per module (codegen is relatively expensive).""" + config_path = Path(request.fspath).parent / "config" / "layout_update_test.yaml" + original_path = CORE.config_path + try: + CORE.config_path = config_path + CORE.config = read_config({}) + generate_cpp_contents(CORE.config) + return CORE.cpp_global_section + CORE.cpp_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_widget_flex_update_applies_partial_options(main_cpp: str) -> None: + """``lvgl.widget.update`` changes only the flex options that are specified, + via the appropriate ``lv_obj_set_style_...``/``lv_obj_set_flex_flow`` + calls on the target widget.""" + assert "lv_obj_set_flex_flow(flex_box, LV_FLEX_FLOW_COLUMN)" in main_cpp + assert ( + "lv_obj_set_style_flex_main_place(flex_box, LV_FLEX_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + assert ( + "lv_obj_set_style_flex_cross_place(flex_box, LV_FLEX_ALIGN_END, LV_STATE_DEFAULT)" + in main_cpp + ) + assert "lv_obj_set_style_pad_row(flex_box, 7, LV_STATE_DEFAULT)" in main_cpp + + +def test_widget_flex_update_does_not_change_type(main_cpp: str) -> None: + """The update must not re-establish the layout type: ``lv_obj_set_layout`` + is emitted once (at creation) and never from the update action.""" + assert main_cpp.count("lv_obj_set_layout(flex_box,") == 1 + + +def test_widget_flex_update_is_partial(main_cpp: str) -> None: + """An option that was not specified in the update (the track placement) is + only set at creation, not by the partial update.""" + assert main_cpp.count("lv_obj_set_style_flex_track_place(flex_box,") == 1 + + +def test_widget_grid_update_applies_alignments(main_cpp: str) -> None: + """``lvgl.widget.update`` on a grid container changes its alignment + options without touching the grid structure.""" + assert ( + "lv_obj_set_style_grid_column_align(grid_box, LV_GRID_ALIGN_SPACE_BETWEEN, " + "LV_STATE_DEFAULT)" in main_cpp + ) + assert ( + "lv_obj_set_style_grid_row_align(grid_box, LV_GRID_ALIGN_CENTER, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_grid_update_does_not_regenerate_descriptor_arrays(main_cpp: str) -> None: + """The grid row/column descriptor arrays are structural and generated once + at creation; an update must not regenerate them.""" + assert main_cpp.count("grid_box_row_dsc") != 0 + # The descriptor array is declared once and referenced once at creation. + assert main_cpp.count("grid_box_row_dsc") == main_cpp.count("grid_box_column_dsc") + assert "lv_obj_set_layout(grid_box," in main_cpp + assert main_cpp.count("lv_obj_set_layout(grid_box,") == 1 + + +def test_top_level_layout_targets_active_screen(main_cpp: str) -> None: + """A top-level ``lvgl.update: { layout: ... }`` applies to the active + screen, not to the LVGL component object.""" + assert ( + "lv_obj_set_flex_flow(lvgl_id->get_screen_active(), LV_FLEX_FLOW_COLUMN)" + in main_cpp + ) + assert ( + "lv_obj_set_style_pad_column(lvgl_id->get_screen_active(), 5, LV_STATE_DEFAULT)" + in main_cpp + ) + + +def test_top_layer_layout_applied(main_cpp: str) -> None: + """A layout under ``top_layer`` is applied to the display's top layer.""" + assert "lv_display_get_layer_top(lvgl_id->get_disp())" in main_cpp + assert "lv_obj_set_flex_flow(top_layer_VAR_, LV_FLEX_FLOW_ROW)" in main_cpp + + +def test_bottom_layer_styling_applied(main_cpp: str) -> None: + """A ``bottom_layer`` style update generates code (previously the layer + keys of ``lvgl.update`` were silently ignored).""" + assert "lv_display_get_layer_bottom(lvgl_id->get_disp())" in main_cpp + assert ( + "lv_obj_set_style_bg_color(bottom_layer_VAR_, lv_color_make(18, 52, 86), " + "LV_PART_MAIN)" in main_cpp + ) diff --git a/tests/component_tests/lvgl/test_paused.py b/tests/component_tests/lvgl/test_paused.py new file mode 100644 index 0000000000..eede17ec19 --- /dev/null +++ b/tests/component_tests/lvgl/test_paused.py @@ -0,0 +1,35 @@ +"""Tests for the LVGL ``paused`` option code generation.""" + +from __future__ import annotations + +import re + +_SET_PAUSED_RE = re.compile(r"->set_paused\((.+?)\);") + + +def _extract_set_paused(main_cpp: str) -> list[str]: + """Return the normalised argument text of every set_paused() call found. + + Whitespace within and around the arguments is collapsed so unrelated + code-generation formatting changes don't break these tests. + """ + return [" ".join(m.group(1).split()) for m in _SET_PAUSED_RE.finditer(main_cpp)] + + +class TestPausedCodeGeneration: + """Verify that the ``paused`` option drives the set_paused() call.""" + + def test_paused_true_generates_set_paused( + self, generate_main, component_config_path + ): + """``paused: true`` emits a set_paused(true, false) call.""" + main_cpp = generate_main(component_config_path("paused.yaml")) + calls = _extract_set_paused(main_cpp) + assert calls == ["true, false"] + + def test_paused_default_omits_set_paused( + self, generate_main, component_config_path + ): + """Without ``paused`` (default false) no set_paused call is generated.""" + main_cpp = generate_main(component_config_path("not_paused.yaml")) + assert _extract_set_paused(main_cpp) == [] diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index c14abdb4fd..6259d85184 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -1,6 +1,7 @@ """Tests for mpi_dsi configuration validation.""" from collections.abc import Callable +import logging from pathlib import Path import pytest @@ -71,6 +72,18 @@ def test_configuration_errors(set_core_config: SetCoreConfigCallable) -> None: } ) + # DSI displays cannot swap axes; enabling swap_xy reports a clear error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + "transform": {"mirror_x": True, "mirror_y": True, "swap_xy": True}, + } + ) + def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: """Test successful configuration validation.""" @@ -116,6 +129,59 @@ def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: CONFIG_SCHEMA(config) +def test_deprecated_model_warning( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecated M5Stack-Tab5-v2 alias warns and points at the replacement models.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "deprecated_display", "model": "M5Stack-Tab5-v2"}) + assert "M5STACK-TAB5-V2 is deprecated" in caplog.text + # The warning names the replacement models so users know what to switch to. + assert "M5STACK-TAB5-ST7123" in caplog.text + + # The replacement models validate without emitting a deprecation warning. + caplog.clear() + with caplog.at_level(logging.WARNING): + CONFIG_SCHEMA({"id": "st7123_display", "model": "M5Stack-Tab5-ST7123"}) + CONFIG_SCHEMA({"id": "st7121_display", "model": "M5Stack-Tab5-ST7121"}) + assert "deprecated" not in caplog.text + + +def test_metadata_records_rotation(set_core_config: SetCoreConfigCallable) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-p4-evboard", KEY_VARIANT: VARIANT_ESP32P4}, + ) + + from esphome.components.display import get_display_metadata + from esphome.components.mipi_dsi.display import CONFIG_SCHEMA + + base = { + "model": "custom", + "init_sequence": [[0xA0, 0x01]], + "lane_bit_rate": "1.5Gbps", + "dimensions": {"width": 320, "height": 240}, + } + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 + + def test_code_generation( generate_main: Callable[[str | Path], str], component_fixture_path: Callable[[str], Path], diff --git a/tests/dashboard/__init__.py b/tests/component_tests/mipi_rgb/__init__.py similarity index 100% rename from tests/dashboard/__init__.py rename to tests/component_tests/mipi_rgb/__init__.py diff --git a/tests/component_tests/mipi_rgb/test_init.py b/tests/component_tests/mipi_rgb/test_init.py new file mode 100644 index 0000000000..0ab6c022e6 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_init.py @@ -0,0 +1,89 @@ +"""Tests for mipi_rgb configuration validation, in particular the per-model +``requires`` component check (see esphome.components.mipi.DriverChip.check_requirements).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA + +# Importing pca9554 registers its pin schema with pins.PIN_SCHEMA_REGISTRY so that +# models (e.g. SEEED-INDICATOR-D1) that reference pca9554-backed pins in their +# defaults can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.pca9554 # noqa: F401 +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _validated(config: ConfigType) -> ConfigType: + """Run the component config schema followed by the final validation.""" + config = CONFIG_SCHEMA(config) + FINAL_VALIDATE_SCHEMA(config) + return config + + +def test_model_requires_psram(set_core_config: SetCoreConfigCallable) -> None: + """A model known to have PSRAM on its board rejects a config without it. + + RGB parallel displays always need a full framebuffer, so every model in this + component is expected to carry ``requires={"psram", ...}``. This board has no + other requirements, so its check is exercised in isolation here. + """ + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, + match=r"ESP32-8048S070 requires component 'psram' to be configured", + ): + _validated({"model": "ESP32-8048S070"}) + + +def test_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "ESP32-8048S070"}) + assert config["model"] == "ESP32-8048S070" + + +def test_model_requires_psram_and_expander( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """A model that also depends on an I2C GPIO expander lists both when missing.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # Only satisfy one of the two requirements. + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + with pytest.raises( + cv.Invalid, + match=r"SEEED-INDICATOR-D1 requires component 'pca9554' to be configured", + ): + _validated( + { + "model": "SEEED-INDICATOR-D1", + "spi_id": "spi_bus", + } + ) diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py new file mode 100644 index 0000000000..e85327c0ab --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -0,0 +1,137 @@ +"""Tests for mipi_rgb configuration validation.""" + +import pytest + +from esphome import config_validation as cv + +# Importing these registers their pin schemas with pins.PIN_SCHEMA_REGISTRY so that +# models referencing IO-expander-backed pins in their defaults (e.g. the LilyGO +# T-RGB boards via xl9535, SEEED-INDICATOR-D1 via pca9554, or the Waveshare panels +# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test. +import esphome.components.ch422g # noqa: F401 +from esphome.components.display import get_display_metadata +from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3 +import esphome.components.pca9554 # noqa: F401 +import esphome.components.xl9535 # noqa: F401 +from esphome.const import ( + CONF_BLUE, + CONF_DIMENSIONS, + CONF_GREEN, + CONF_HEIGHT, + CONF_INIT_SEQUENCE, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_RED, + CONF_SWAP_XY, + CONF_WIDTH, + KEY_VARIANT, + PlatformFramework, +) +from tests.component_tests.types import SetCoreConfigCallable + +# A generic set of data pins so that models without a default pin assignment +# (e.g. CUSTOM and RPI) still validate. +DATA_PINS = { + CONF_RED: [1, 2, 3, 4, 5], + CONF_GREEN: [6, 7, 8, 9, 10, 11], + CONF_BLUE: [12, 13, 14, 15, 16], +} + + +def _set_s3(set_core_config: SetCoreConfigCallable) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={ + KEY_BOARD: "esp32-s3-devkitc-1", + KEY_VARIANT: VARIANT_ESP32S3, + }, + ) + + +def test_configuration_success(set_core_config: SetCoreConfigCallable) -> None: + """Every predefined model validates once required defaults are supplied.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + for name, model in MODELS.items(): + config = {"model": name, "data_pins": DATA_PINS, "pclk_pin": 21} + if model.initsequence is None: + config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]] + if not model.get_default(CONF_WIDTH): + config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480} + CONFIG_SCHEMA(config) + + +def test_transform_matches_model_support( + set_core_config: SetCoreConfigCallable, +) -> None: + """The transform schema only accepts the axes a model actually supports.""" + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # never support axis swapping). + model = MODELS["ESP32-8048S070"] + assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) + + # An unsupported axis may be explicitly disabled (a harmless no-op)... + CONFIG_SCHEMA( + {**base, "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": False}} + ) + + # ...but enabling it reports a clear, model-specific error. + with pytest.raises(cv.Invalid, match="'swap_xy' is not supported by this model"): + CONFIG_SCHEMA( + { + **base, + "transform": {"mirror_x": True, "mirror_y": False, "swap_xy": True}, + } + ) + + +def test_st7701s_only_supports_mirror_x( + set_core_config: SetCoreConfigCallable, +) -> None: + """ST7701S panels shorter than full height only expose mirror_x. + + mirror_y only works at full height (864px), so the LilyGO 480px panels must + reject a mirror_y transform. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["T-RGB-2.1"] + assert model.transforms == {CONF_MIRROR_X} + assert CONF_SWAP_XY not in model.transforms + + base = {"model": "T-RGB-2.1"} + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + with pytest.raises(cv.Invalid, match="'mirror_y' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata. + + LVGL relies on this to flag a rotation set in the display config (see the + mipi_spi tests for the end-to-end LVGL rejection). + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + config = CONFIG_SCHEMA({**base, "id": "rotated", "rotation": 90}) + assert get_display_metadata(config["id"]).rotation == 90 + + config = CONFIG_SCHEMA({**base, "id": "unrotated"}) + assert get_display_metadata(config["id"]).rotation == 0 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_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py index e7f5143d91..06cc8ee09a 100644 --- a/tests/component_tests/mipi_spi/test_display_metadata.py +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -3,6 +3,9 @@ from collections.abc import Callable from pathlib import Path +import pytest + +from esphome import config_validation as cv from esphome.components.const import BYTE_ORDER_BIG from esphome.components.display import get_all_display_metadata, get_display_metadata from esphome.components.esp32 import ( @@ -13,6 +16,7 @@ from esphome.components.esp32 import ( ) from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import PlatformFramework +from esphome.core import ID from tests.component_tests.types import SetCoreConfigCallable @@ -23,6 +27,18 @@ def validated_config(config): return config +def _lvgl_config(display_id: str) -> dict: + """Build a minimal LVGL config dict referencing the given display id.""" + return { + "displays": [ID(display_id, True)], + "log_level": "WARN", + "color_depth": 16, + "transparency_key": 0x000400, + "draw_rounding": 2, + "buffer_size": 0, + } + + def test_metadata_native_quad_default_test_card( set_core_config: SetCoreConfigCallable, ) -> None: @@ -91,7 +107,7 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation( PlatformFramework.ESP32_IDF, platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) - # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + # JC3248W535 has transforms={mirror_x, mirror_y} only config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"}) meta = get_display_metadata(config["id"]) assert meta is not None @@ -166,3 +182,69 @@ def test_metadata_via_code_generation_lvgl( assert meta.height == 160 assert meta.has_hardware_rotation is True assert meta.byte_order == BYTE_ORDER_BIG + + +def test_metadata_records_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A configured display rotation is recorded in the metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA( + {"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90} + ) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 90 + + +def test_metadata_rotation_defaults_to_zero( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation reports rotation 0 in its metadata.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + meta = get_display_metadata(config["id"]) + assert meta is not None + assert meta.rotation == 0 + + +def test_rotation_flagged_when_used_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display with a rotation is rejected when driven by LVGL. + + LVGL manages its own rotation, so a rotation set in the display config must be + flagged and the user directed to configure it in the LVGL block instead. This + exercises the full chain: the mipi_spi schema records the rotation in the + display metadata, and LVGL's final validation reports it. + """ + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "rotated", "rotation": 90}) + with pytest.raises(cv.Invalid, match="rotation.*not compatible with LVGL"): + final_validation([_lvgl_config("rotated")]) + + +def test_no_rotation_accepted_with_lvgl( + set_core_config: SetCoreConfigCallable, +) -> None: + """A display without a rotation validates cleanly when driven by LVGL.""" + from esphome.components.lvgl import final_validation + + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CONFIG_SCHEMA({"model": "ST7735", "dc_pin": 18, "id": "unrotated"}) + # Should not raise. + final_validation([_lvgl_config("unrotated")]) diff --git a/tests/component_tests/mipi_spi/test_final_validate.py b/tests/component_tests/mipi_spi/test_final_validate.py index 8c45b47752..77111ae867 100644 --- a/tests/component_tests/mipi_spi/test_final_validate.py +++ b/tests/component_tests/mipi_spi/test_final_validate.py @@ -6,10 +6,13 @@ from typing import Any import pytest +from esphome import config_validation as cv from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.mipi import DriverChip from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA from esphome.const import CONF_BUFFER_SIZE, PlatformFramework +from esphome.core import CORE from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -183,3 +186,77 @@ def test_buffer_size_selected_when_lvgl_with_test_card( ) assert config[CONF_BUFFER_SIZE] == pytest.approx(1.0 / 4) + + +def test_requires_missing_single_component_raises() -> None: + """A model that requires a single component raises when it is absent.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-PSRAM", requires={"psram"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-PSRAM requires component 'psram' to be configured", + ): + chip.check_requirements() + + +def test_requires_missing_multiple_components_raises() -> None: + """A model that requires several components lists all the missing ones, pluralized.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-MULTI", requires={"psram", "pca9554"}) + + with pytest.raises( + cv.Invalid, + match=r"TEST-REQUIRES-MULTI requires components '.*' to be configured", + ) as excinfo: + chip.check_requirements() + assert "psram" in str(excinfo.value) + assert "pca9554" in str(excinfo.value) + + +def test_requires_satisfied_does_not_raise() -> None: + """No error is raised once all the required components are configured.""" + CORE.raw_config = {"psram": True, "pca9554": []} + chip = DriverChip("TEST-REQUIRES-SATISFIED", requires={"psram", "pca9554"}) + + chip.check_requirements() # Should not raise + + +def test_requires_absent_does_not_raise() -> None: + """Models without a requires set are unaffected by the check.""" + CORE.raw_config = {} + chip = DriverChip("TEST-REQUIRES-NONE") + + chip.check_requirements() # Should not raise + + +def test_predefined_model_requires_psram( + set_core_config: SetCoreConfigCallable, +) -> None: + """A predefined board model known to have PSRAM rejects a config without it.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + CORE.raw_config = {} + + with pytest.raises( + cv.Invalid, match=r"S3BOX requires component 'psram' to be configured" + ): + _validated({"model": "s3box"}) + + +def test_predefined_model_requires_psram_satisfied( + set_core_config: SetCoreConfigCallable, + set_component_config: Any, +) -> None: + """The same board model validates once PSRAM is configured.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + set_component_config("psram", True) + CORE.raw_config = {"psram": True} + + config = _validated({"model": "s3box"}) + assert config["model"] == "S3BOX" diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 8edbe095b7..f29883684c 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -136,7 +136,7 @@ def test_dimension_validation( "model": "JC3248W535", "transform": {"mirror_x": False, "mirror_y": True, "swap_xy": True}, }, - "Axis swapping not supported by this model", + "'swap_xy' is not supported by this model", id="axis_swapping_not_supported", ), pytest.param( @@ -361,7 +361,8 @@ def test_native_generation( "mipi_spi::MipiSpiBuffer()" in main_cpp ) - assert "set_init_sequence({240, 1, 8, 242" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 240, 1, 8, 242" in main_cpp assert "show_test_card();" in main_cpp assert "set_write_only(true);" in main_cpp @@ -377,6 +378,76 @@ def test_lvgl_generation( "mipi_spi::MipiSpi();" in main_cpp ) - assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp + # A 10ms post-reset delay ({10, 255}) is prepended ahead of the model commands. + assert "set_init_sequence({10, 255, 177, 3, 1, 44, 45, 178" in main_cpp assert "show_test_card();" not in main_cpp assert "set_auto_clear(false);" in main_cpp + + +# A 10ms delay (flattened to {10, 0xFF}, where 0xFF is the delay marker byte) is +# always prepended to the init sequence, since both a software and a hardware reset +# need to settle before further commands. A custom model has no reset_pin default +# and does not set no_swreset, so when no reset pin is configured the SWRESET command +# ({1, 0}: command 0x01 with no parameters) is prepended ahead of that delay. +_SWRESET_YAML = """ +esphome: + name: swreset-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +spi: + clk_pin: 1 + mosi_pin: 2 +display: + - platform: mipi_spi + model: custom + id: {display_id} + dc_pin: 4 + cs_pin: 8 + dimensions: + width: 320 + height: 240 + init_sequence: + - [0xA0, 0x01] +{reset_line} +""" + + +def test_swreset_prepended_without_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A model with no reset pin (and no no_swreset) gets SWRESET prepended.""" + yaml_file = tmp_path / "swreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format(display_id="swreset_display", reset_line="") + ) + + main_cpp = generate_main(yaml_file) + + # SWRESET ({1, 0}) followed by a 10ms delay ({10, 255}) is inserted ahead of + # the model's own commands. + assert "swreset_display->set_init_sequence({1, 0, 10, 255, 160, 1, 1," in main_cpp + + +def test_swreset_not_prepended_with_reset_pin( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """A hardware reset pin performs the reset, so SWRESET must not be prepended. + + The post-reset delay is still required, so the sequence starts with the delay. + """ + yaml_file = tmp_path / "hwreset.yaml" + yaml_file.write_text( + _SWRESET_YAML.format( + display_id="hwreset_display", reset_line=" reset_pin: 5" + ) + ) + + main_cpp = generate_main(yaml_file) + + # The delay ({10, 255}) is still present, but no leading SWRESET ({1, 0}). + assert "hwreset_display->set_init_sequence({10, 255, 160, 1, 1," in main_cpp + assert "hwreset_display->set_init_sequence({1, 0," not 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 index 7ae6f0e61f..b2b421c1e2 100644 --- a/tests/component_tests/mipi_spi/test_padding_and_offsets.py +++ b/tests/component_tests/mipi_spi/test_padding_and_offsets.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from pathlib import Path +from typing import Any import pytest @@ -222,6 +223,7 @@ class TestNewModelVariants: def test_m5core2_with_native_dimensions( self, set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], ) -> None: """Test M5CORE2 variant with reset native_width and native_height.""" set_core_config( @@ -231,6 +233,8 @@ class TestNewModelVariants: KEY_VARIANT: VARIANT_ESP32S3, }, ) + # M5CORE2 has PSRAM on board and requires it to be configured + set_component_config("psram", True) # M5CORE2 should validate successfully config = validated_config({"model": "M5CORE2"}) diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py new file mode 100644 index 0000000000..7c978a5cd5 --- /dev/null +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -0,0 +1,84 @@ +"""Tests for modbus_server configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.modbus_server import ( + SERVER_SENSOR_VALUE_TYPE, + _validate_no_overlapping_registers, + _validate_register_ranges, +) +from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE +from esphome.const import CONF_ADDRESS + + +def _config(registers: list[tuple[int, str]]) -> dict: + return { + CONF_REGISTERS: [ + {CONF_ADDRESS: address, CONF_VALUE_TYPE: value_type} + for address, value_type in registers + ] + } + + +def test_non_overlapping_registers_pass() -> None: + # Values that tile the address space without gaps or overlaps are accepted. + config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_registers_with_gaps_pass() -> None: + config = _config([(0x00, "U_WORD"), (0x05, "U_QWORD"), (0x20, "U_WORD")]) + assert _validate_no_overlapping_registers(config) is config + + +def test_no_registers_pass() -> None: + assert _validate_no_overlapping_registers({}) == {} + + +def test_duplicate_address_rejected() -> None: + config = _config([(0x10, "U_WORD"), (0x10, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_multi_register_value_overlapping_neighbour_rejected() -> None: + # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. + config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_overlap_detected_regardless_of_order() -> None: + # The U_DWORD at 0x10 covers 0x10-0x11 and overlaps the U_WORD at 0x11 even when declared after it. + config = _config([(0x11, "U_WORD"), (0x10, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="overlaps"): + _validate_no_overlapping_registers(config) + + +def test_register_span_within_address_space_pass() -> None: + # A value whose span ends exactly at 0xFFFF is fine (U_QWORD at 0xFFFC covers 0xFFFC-0xFFFF). + config = _config([(0xFFFF, "U_WORD"), (0xFFFC, "U_QWORD")]) + assert _validate_register_ranges(config) is config + + +def test_register_span_past_end_rejected() -> None: + # U_QWORD at 0xFFFE would need 0xFFFE-0x10001, running off the 16-bit address space. + config = _config([(0xFFFE, "U_QWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_multi_register_value_at_last_address_rejected() -> None: + # A U_DWORD at 0xFFFF needs a second register at 0x10000, which does not exist. + config = _config([(0xFFFF, "U_DWORD")]) + with pytest.raises(cv.Invalid, match="past the end"): + _validate_register_ranges(config) + + +def test_raw_value_type_rejected() -> None: + # RAW has no numeric encoding, so it is not offered as a server register type. + validator = cv.enum(SERVER_SENSOR_VALUE_TYPE) + with pytest.raises(cv.Invalid): + validator("RAW") + assert validator("U_WORD") == "U_WORD" diff --git a/tests/dashboard/status/__init__.py b/tests/component_tests/online_image/__init__.py similarity index 100% rename from tests/dashboard/status/__init__.py rename to tests/component_tests/online_image/__init__.py diff --git a/tests/component_tests/online_image/config/online_image_platform_test.yaml b/tests/component_tests/online_image/config/online_image_platform_test.yaml new file mode 100644 index 0000000000..9b92bf75d0 --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_platform_test.yaml @@ -0,0 +1,33 @@ +# New `image:` `platform: online_image` form. Exercises online_image/image.py +# through the real platform loader and codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +image: + - platform: online_image + id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +psram: + mode: quad + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/config/online_image_test.yaml b/tests/component_tests/online_image/config/online_image_test.yaml new file mode 100644 index 0000000000..4af398cdff --- /dev/null +++ b/tests/component_tests/online_image/config/online_image_test.yaml @@ -0,0 +1,32 @@ +# Legacy top-level `online_image:` form. Exercises the deprecation shim and the +# shared codegen path through the real read_config/codegen pipeline. +esphome: + name: test + +esp32: + board: esp32s3box + +wifi: + ssid: MySSID + password: password1 + +http_request: + verify_ssl: false + +online_image: + - id: test_online_image + url: http://example.com/image.png + format: png + type: rgb565 + +spi: + mosi_pin: 6 + clk_pin: 7 + +psram: + mode: quad + +display: + - platform: mipi_spi + id: lcd_display + model: s3box diff --git a/tests/component_tests/online_image/test_init.py b/tests/component_tests/online_image/test_init.py new file mode 100644 index 0000000000..76b00ff5ff --- /dev/null +++ b/tests/component_tests/online_image/test_init.py @@ -0,0 +1,76 @@ +"""Tests for the online_image platform and the legacy `online_image:` shim.""" + +from __future__ import annotations + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components.online_image import ( + DOMAIN, + LEGACY_REMOVAL_VERSION, + _capture_legacy_entry, + _warn_legacy_online_image, +) +from esphome.core import CORE +from esphome.types import ConfigType + +# --------------------------------------------------------------------------- +# Legacy top-level `online_image:` deprecation shim -- REMOVE these tests after +# 2027.1.0 together with the shim in esphome/components/online_image/__init__.py. +# --------------------------------------------------------------------------- + + +def test_warn_legacy_online_image_warns_once( + caplog: pytest.LogCaptureFixture, +) -> None: + """The deprecation warning fires exactly once and never mutates the config.""" + config: ConfigType = {"id": "test_online_image", "url": "http://example.com/i.png"} + + # A per-entry capture (CONFIG_SCHEMA step) records the raw entry so the + # one-shot warning can print a pasteable migrated block. + assert _capture_legacy_entry(config) is config + + with caplog.at_level(logging.WARNING): + # First call: flag not yet set -> warns and records the flag. + assert _warn_legacy_online_image(config) is config + # Second call: flag already set -> stays silent (the dedup branch). + assert _warn_legacy_online_image(config) is config + + assert CORE.data[DOMAIN]["legacy_warning_shown"] is True + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "deprecated" in caplog.text + assert "platform: online_image" in caplog.text + assert LEGACY_REMOVAL_VERSION in caplog.text + + +def test_legacy_online_image_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """The legacy `online_image:` block validates, warns, and generates codegen + through the real read_config/codegen pipeline.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(component_config_path("online_image_test.yaml")) + + # Deprecation warning surfaced through the real validation pipeline. + assert "online_image" in caplog.text + assert "deprecated" in caplog.text + + # setup_online_image ran: OnlineImage object constructed and parented. + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp + + +def test_online_image_platform_generation( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The `image:` `platform: online_image` form generates codegen through the + real platform loader (online_image/image.py) without a deprecation warning.""" + main_cpp = generate_main(component_config_path("online_image_platform_test.yaml")) + + assert "new(test_online_image) online_image::OnlineImage(" in main_cpp diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 66f946a5bd..6990c1c051 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -12,7 +12,6 @@ from esphome.components.packages import ( _substitute_package_definition, _walk_packages, do_packages_pass, - is_package_definition, merge_packages, resolve_packages, ) @@ -89,44 +88,6 @@ def packages_pass(config): return config -_INCLUDE_FILE = "INCLUDE_FILE" - - -@pytest.mark.parametrize( - ("value", "expected"), - [ - # IncludeFile objects are package definitions - (_INCLUDE_FILE, True), - # Git URL shorthand strings are package definitions - ("github://esphome/firmware/base.yaml@main", True), - # Remote package dicts (with url key) are package definitions - ({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True), - # Plain config dicts are NOT package definitions (they are config fragments) - ({"wifi": {"ssid": "test"}}, False), - # None is not a package definition - (None, False), - # Lists are not package definitions - ([{"wifi": {"ssid": "test"}}], False), - # Empty dicts are not package definitions - ({}, False), - ], - ids=[ - "include_file", - "git_shorthand", - "remote_package", - "config_fragment", - "none", - "list", - "empty_dict", - ], -) -def test_is_package_definition(value: object, expected: bool) -> None: - """Test that is_package_definition correctly identifies package definitions.""" - if value is _INCLUDE_FILE: - value = MagicMock(spec=IncludeFile) - assert is_package_definition(value) is expected - - def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. @@ -210,30 +171,6 @@ def test_package_include(basic_wifi, basic_esphome) -> None: assert actual == expected -def test_single_package( - basic_esphome, - basic_wifi, - caplog: pytest.LogCaptureFixture, -) -> None: - """ - Tests the simple case where a single package is added to the top-level config as is. - In this test, the CONF_WIFI config is expected to be simply added to the top-level config. - This tests the case where the user just put packages: !include package.yaml, not - part of a list or mapping of packages. - This behavior is deprecated, the test also checks if a warning is issued. - """ - config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}} - - expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi} - - with caplog.at_level("WARNING"): - actual = packages_pass(config) - - assert actual == expected - - assert "This method for including packages will go away in 2026.7.0" in caplog.text - - def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -1154,6 +1091,10 @@ def test_packages_include_file_resolves_to_invalid_type_raises( 6, "some string", True, + None, + ["some string"], + {"some_component": 8}, + {3: 2}, ], ) def test_invalid_package_contents_rejected(invalid_package: object) -> None: @@ -1167,28 +1108,15 @@ def test_invalid_package_contents_rejected(invalid_package: object) -> None: do_packages_pass(config) -@pytest.mark.xfail( - reason="Deprecated single-package fallback swallows these errors. " - "Remove xfail when single-package deprecation is removed (2026.7.0).", - strict=True, -) -@pytest.mark.parametrize( - "invalid_package", - [ - None, - ["some string"], - {"some_component": 8}, - {3: 2}, - ], -) -def test_invalid_package_contents_masked_by_deprecation( - invalid_package: object, -) -> None: - """These invalid packages are swallowed by the deprecated single-package fallback.""" +def test_single_package_fragment_form_rejected() -> None: + """The deprecated single-package form is removed and now raises. + + Previously ``packages: !include some_package.yaml`` resolving to a bare config + fragment dict was silently wrapped and merged via the single-package fallback. + That form must now raise instead of being accepted. + """ config = { - CONF_PACKAGES: { - "some_package": invalid_package, - }, + CONF_PACKAGES: {CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}}, } with pytest.raises(cv.Invalid): do_packages_pass(config) @@ -1231,14 +1159,10 @@ def test_named_dict_with_include_files_no_false_deprecation_warning( assert "deprecated" not in caplog.text.lower() -def test_validate_deprecated_false_raises_directly( +def test_named_package_errors_raise_directly( caplog: pytest.LogCaptureFixture, ) -> None: - """With validate_deprecated=False, errors raise directly without fallback. - - This is the codepath used for remote packages where _process_remote_package - returns already-resolved dicts that is_package_definition cannot detect. - """ + """Errors processing a named-dict package raise directly, with no deprecation warning.""" config = { CONF_PACKAGES: { "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, @@ -1261,7 +1185,7 @@ def test_validate_deprecated_false_raises_directly( caplog.at_level(logging.WARNING), pytest.raises(cv.Invalid, match="nested error"), ): - _walk_packages(config, failing_callback, validate_deprecated=False) + _walk_packages(config, failing_callback) assert "deprecated" not in caplog.text.lower() @@ -1296,40 +1220,6 @@ def test_error_on_first_declared_package_still_detected() -> None: _walk_packages(config, fail_on_last) -def test_deprecated_single_package_fallback_still_works( - caplog: pytest.LogCaptureFixture, -) -> None: - """The deprecated single-package form still falls back at the top level. - - When a dict's values are plain config fragments (not package definitions) - and the callback fails, the deprecated fallback wraps the dict in a list - and retries with a deprecation warning. - """ - config = { - CONF_PACKAGES: { - CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"}, - }, - } - - attempt = 0 - - def fail_then_succeed( - package_config: dict, context: object, path: DocumentPath | None = None - ) -> dict: - nonlocal attempt - attempt += 1 - if attempt == 1: - # First attempt: treating as named dict fails - raise cv.Invalid("not a valid package") - # Second attempt: after fallback wraps as list, succeeds - return package_config - - with caplog.at_level(logging.WARNING): - _walk_packages(config, fail_then_succeed) - - assert "deprecated" in caplog.text.lower() - - def test_merge_packages_invalid_nested_type_raises() -> None: """Invalid nested packages type during merge raises cv.Invalid.""" config = { diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py new file mode 100644 index 0000000000..07f5065241 --- /dev/null +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -0,0 +1,84 @@ +"""Tests for the provisioning component config validation.""" + +from __future__ import annotations + +import logging + +import pytest + +from esphome import config_validation as cv +from esphome.components.provisioning import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + register_source, + report_hardcoded_credentials, +) +from esphome.const import CONF_TIMEOUT, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_provisioning_requires_a_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """Provisioning with no registered source is a config error. + + Sources register themselves during their own config validation; with none + registered the window could never resolve, so validation fails. + """ + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match="provisioning-capable component"): + FINAL_VALIDATE_SCHEMA({}) + + +def test_provisioning_accepts_a_registered_source( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that registered as a provisioning source satisfies validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + # Should not raise. + assert FINAL_VALIDATE_SCHEMA({}) == {} + + +def test_provisioning_warns_on_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """A source with credentials set in the config triggers a warning.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + report_hardcoded_credentials("wifi") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "wifi" in caplog.text + assert "credentials" in caplog.text + + +def test_provisioning_no_warning_without_hardcoded_credentials( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No credentials warning when no source reports hardcoded credentials.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + with caplog.at_level(logging.WARNING): + assert FINAL_VALIDATE_SCHEMA({}) == {} + assert "credentials" not in caplog.text + + +def test_provisioning_rejects_zero_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A zero timeout would leave the window open forever, so it is rejected.""" + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({CONF_TIMEOUT: "0s"}) + + +def test_provisioning_accepts_positive_timeout( + set_core_config: SetCoreConfigCallable, +) -> None: + """A positive timeout is accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = CONFIG_SCHEMA({CONF_TIMEOUT: "5min"}) + assert config[CONF_TIMEOUT].total_milliseconds == 300000 diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index ea4adc69a9..4a1ed2c72a 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -12,9 +12,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ) import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, PlatformFramework @@ -25,21 +28,26 @@ UNSUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32H21, ] SUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C5, + VARIANT_ESP32H4, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] SUPPORTED_PSRAM_MODES = { VARIANT_ESP32: ["quad"], VARIANT_ESP32C5: ["quad"], + VARIANT_ESP32H4: ["quad"], VARIANT_ESP32P4: ["hex"], VARIANT_ESP32S2: ["quad"], VARIANT_ESP32S3: ["quad", "octal"], + VARIANT_ESP32S31: ["octal"], } @@ -187,7 +195,7 @@ def _setup_psram_final_validation_test( {"mode": "octal"}, {"variant": "ESP32"}, True, - r"Octal PSRAM is only supported on ESP32-S3", + r"Octal PSRAM is not supported on ESP32", id="octal_mode_only_esp32s3", ), pytest.param( diff --git a/tests/component_tests/web_server/test_private_network_access.py b/tests/component_tests/web_server/test_private_network_access.py new file mode 100644 index 0000000000..87911c5f9b --- /dev/null +++ b/tests/component_tests/web_server/test_private_network_access.py @@ -0,0 +1,86 @@ +"""Tests for web_server Private Network Access / allowed_origins validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server import ( + CONF_ALLOWED_ORIGINS, + validate_origin, + validate_private_network_access, +) +from esphome.const import CONF_ENABLE_PRIVATE_NETWORK_ACCESS +from esphome.types import ConfigType + + +def test_pna_enabled_without_origins_fails() -> None: + """Enabling PNA without allowed_origins must fail validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True} + + with pytest.raises(cv.Invalid) as exc_info: + validate_private_network_access(config) + + error_msg = str(exc_info.value) + assert CONF_ALLOWED_ORIGINS in error_msg + assert "must be set" in error_msg + + +def test_pna_enabled_with_origins_passes() -> None: + """Enabling PNA with at least one allowed origin passes validation.""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: True, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_origins_without_pna_passes() -> None: + """allowed_origins can be set without enabling PNA (they are independent).""" + config: ConfigType = { + CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False, + CONF_ALLOWED_ORIGINS: ["https://app.esphome.io"], + } + assert validate_private_network_access(config) == config + + +def test_pna_disabled_without_origins_passes() -> None: + """PNA disabled and no origins specified passes validation.""" + config: ConfigType = {CONF_ENABLE_PRIVATE_NETWORK_ACCESS: False} + assert validate_private_network_access(config) == config + + +def test_validate_origin_wildcard() -> None: + """The '*' wildcard is accepted as-is.""" + assert validate_origin("*") == "*" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com", + "http://example.com:8080", + "https://192.168.1.5", + ], +) +def test_validate_origin_valid(value: str) -> None: + """Well-formed origins pass through unchanged.""" + assert validate_origin(value) == value + + +def test_validate_origin_lowercased() -> None: + """Scheme and host are normalized to lowercase to match the browser Origin header.""" + assert validate_origin("HTTPS://App.Example.com") == "https://app.example.com" + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/", # trailing slash + "https://example.com/path", # path segment + "example.com", # missing scheme + "", # empty + ], +) +def test_validate_origin_invalid(value: str) -> None: + """Malformed origins are rejected at config time instead of silently 403ing.""" + with pytest.raises(cv.Invalid, match="not a valid origin"): + validate_origin(value) diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py new file mode 100644 index 0000000000..82635b26da --- /dev/null +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -0,0 +1,65 @@ +"""Tests for web_server authentication codegen.""" + +from collections.abc import Callable + +import pytest + +from esphome.core import CORE + +_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0" + + +def _has_define(name: str) -> bool: + return any(d.name == name for d in CORE.defines) + + +def test_web_server_auth_default_is_basic_with_deprecation_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth without an explicit type builds Basic and warns about the upcoming default change.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_default.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING in caplog.text + + +def test_web_server_auth_explicit_basic_no_warning( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type basic builds Basic and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_auth_explicit_digest( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Auth type digest builds Digest and does not warn.""" + generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml") + + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text + + +def test_web_server_without_auth( + generate_main: Callable[[str], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Without an auth block, no auth is compiled in and no warning is emitted.""" + generate_main("tests/component_tests/web_server/web_server_no_auth.yaml") + + assert not _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + assert _DEFAULT_CHANGE_WARNING not in caplog.text diff --git a/tests/component_tests/web_server/web_server_auth_basic.yaml b/tests/component_tests/web_server/web_server_auth_basic.yaml new file mode 100644 index 0000000000..70180f9fbc --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_default.yaml b/tests/component_tests/web_server/web_server_auth_default.yaml new file mode 100644 index 0000000000..076180ab79 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_default.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password diff --git a/tests/component_tests/web_server/web_server_auth_digest.yaml b/tests/component_tests/web_server/web_server_auth_digest.yaml new file mode 100644 index 0000000000..f413787601 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/component_tests/web_server/web_server_no_auth.yaml b/tests/component_tests/web_server/web_server_no_auth.yaml new file mode 100644 index 0000000000..1c7823b4ae --- /dev/null +++ b/tests/component_tests/web_server/web_server_no_auth.yaml @@ -0,0 +1,14 @@ +--- +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/components/adc/test.rp2040-pico2-ard.yaml b/tests/components/adc/test.rp2350-ard.yaml similarity index 100% rename from tests/components/adc/test.rp2040-pico2-ard.yaml rename to tests/components/adc/test.rp2350-ard.yaml diff --git a/tests/components/animation/common.yaml b/tests/components/animation/common.yaml index 8bb2a2f4d8..6790e8439b 100644 --- a/tests/components/animation/common.yaml +++ b/tests/components/animation/common.yaml @@ -1,23 +1,26 @@ -animation: - - id: rgb565_animation +image: + - platform: animation + id: rgb565_animation file: $component_dir/anim.gif type: RGB565 transparency: opaque resize: 50x50 - - id: rgb_animation + - platform: animation + id: rgb_animation file: $component_dir/anim.apng type: RGB transparency: chroma_key resize: 50x50 - - id: grayscale_animation + - platform: animation + id: grayscale_animation file: $component_dir/anim.apng type: grayscale display: lambda: |- id(rgb565_animation).next_frame(); - id(rgb_animation1).next_frame(); - id(grayscale_animation2).next_frame(); + id(rgb_animation).next_frame(); + id(grayscale_animation).next_frame(); it.image(0, 0, rgb565_animation); - it.image(120, 0, rgb_animation1); - it.image(240, 0, grayscale_animation2); + it.image(120, 0, rgb_animation); + it.image(240, 0, grayscale_animation); diff --git a/tests/components/animation/validate.host.yaml b/tests/components/animation/validate.host.yaml new file mode 100644 index 0000000000..d754f34688 --- /dev/null +++ b/tests/components/animation/validate.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `animation:` form (deprecated; migrates to +# `platform: animation`). Config-only test exercising the deprecation path. +display: + - platform: sdl + id: animation_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +animation: + - id: legacy_animation + file: $component_dir/anim.gif + type: RGB565 + transparency: opaque + resize: 50x50 diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 060254990d..d7470ee4b3 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -109,6 +109,34 @@ api: - name.c_str() - int_arr.size() - string_arr.size() + # Test string + array args used by homeassistant.action's deferred + # on_success/on_error response callback. homeassistant.action registers + # synchronous=False, so the api codegen must fall back to owning + # std::string / std::vector args here: the non-owning defaults would + # dangle once rx_buf_ is reused before the response arrives, and the + # non-copyable FixedVector would fail to compile when captured into + # the response callback. + - action: action_response_args + variables: + name: string + int_arr: int[] + then: + - homeassistant.action: + action: notify.notify + data: + message: !lambda 'return name;' + on_success: + - logger.log: + format: "Notified %s (%u ints)" + args: + - name.c_str() + - int_arr.size() + on_error: + - logger.log: + format: "Notify failed (%s): %s" + args: + - error.c_str() + - name.c_str() # Test ContinuationAction (IfAction with then/else branches) - action: test_if_action variables: diff --git a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml index 504871716b..7563e3e9df 100644 --- a/tests/components/api/test-dynamic-encryption.esp32-idf.yaml +++ b/tests/components/api/test-dynamic-encryption.esp32-idf.yaml @@ -1,5 +1,11 @@ -<<: !include common-base.yaml +packages: + common: !include common-base.yaml wifi: ssid: MySSID password: password1 + +# Encryption enabled without a key: compiles both frame helpers so the key +# can be provisioned at runtime (zero-PSK noise or deprecated plaintext) +api: + encryption: diff --git a/tests/components/api/test.nrf52-adafruit.yaml b/tests/components/api/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..347480bab6 --- /dev/null +++ b/tests/components/api/test.nrf52-adafruit.yaml @@ -0,0 +1,7 @@ +<<: !include common.yaml + +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/bl0940/test.esp32-idf.yaml b/tests/components/bl0940/test.esp32-idf.yaml index 64baa4ec9d..e74af834d4 100644 --- a/tests/components/bl0940/test.esp32-idf.yaml +++ b/tests/components/bl0940/test.esp32-idf.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO14 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.esp8266-ard.yaml b/tests/components/bl0940/test.esp8266-ard.yaml index 89ca3ab5ae..f614b0a395 100644 --- a/tests/components/bl0940/test.esp8266-ard.yaml +++ b/tests/components/bl0940/test.esp8266-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/bl0940/test.rp2040-ard.yaml b/tests/components/bl0940/test.rp2040-ard.yaml index b28f2b5e05..c8e2e3b55a 100644 --- a/tests/components/bl0940/test.rp2040-ard.yaml +++ b/tests/components/bl0940/test.rp2040-ard.yaml @@ -3,6 +3,6 @@ substitutions: rx_pin: GPIO5 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_4800: !include ../../test_build_components/common/uart_4800/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/bmi270/common.yaml b/tests/components/bmi270/common.yaml index 0ffb1c6281..0f9b70631c 100644 --- a/tests/components/bmi270/common.yaml +++ b/tests/components/bmi270/common.yaml @@ -3,52 +3,64 @@ sensor: name: "BMI270 Temperature" - platform: motion + motion_id: bmi270_motion type: acceleration_x - name: "Accel X" + name: "BMI270 Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: bmi270_motion type: acceleration_y - name: "Accel Y" + name: "BMI270 Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: bmi270_motion type: acceleration_z - name: "Accel Z" + name: "BMI270 Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: bmi270_motion type: gyroscope_x - name: "Gyro X" + name: "BMI270 Gyro X" - platform: motion + motion_id: bmi270_motion type: gyroscope_y - name: "Gyro Y" + name: "BMI270 Gyro Y" - platform: motion + motion_id: bmi270_motion type: gyroscope_z - name: "Gyro Z" + name: "BMI270 Gyro Z" - platform: motion + motion_id: bmi270_motion type: angular_rate_x - name: "Angular Rate X" + name: "BMI270 Angular Rate X" - platform: motion + motion_id: bmi270_motion type: angular_rate_y - name: "Angular Rate Y" + name: "BMI270 Angular Rate Y" - platform: motion + motion_id: bmi270_motion type: angular_rate_z - name: "Angular Rate Z" + name: "BMI270 Angular Rate Z" - platform: motion + motion_id: bmi270_motion type: pitch - name: "Pitch" + name: "BMI270 Pitch" - platform: motion + motion_id: bmi270_motion type: roll - name: "Roll" + name: "BMI270 Roll" motion: - platform: bmi270 + id: bmi270_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/cst328/common.yaml b/tests/components/cst328/common.yaml new file mode 100644 index 0000000000..286dbf587f --- /dev/null +++ b/tests/components/cst328/common.yaml @@ -0,0 +1,22 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: cst328_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: cst328_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: cst328 + i2c_id: i2c_bus + id: cst328_touchscreen + display: cst328_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} + +binary_sensor: + - platform: cst328 + id: touch_key_cst328 diff --git a/tests/components/cst328/test.esp32-idf.yaml b/tests/components/cst328/test.esp32-idf.yaml new file mode 100644 index 0000000000..ac4ad140a8 --- /dev/null +++ b/tests/components/cst328/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + display_reset_pin: "4" + interrupt_pin: "20" + reset_pin: "21" + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + cst328: !include common.yaml diff --git a/tests/components/cst9220/common.yaml b/tests/components/cst9220/common.yaml new file mode 100644 index 0000000000..99e14f47ae --- /dev/null +++ b/tests/components/cst9220/common.yaml @@ -0,0 +1,16 @@ +display: + - id: cst9220_display + platform: ili9xxx + model: ili9342 + cs_pin: ${cs_pin} + dc_pin: ${dc_pin} + reset_pin: ${disp_reset_pin} + invert_colors: false + +touchscreen: + - id: ts_cst9220 + i2c_id: i2c_bus + platform: cst9220 + display: cst9220_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/cst9220/test.esp32-idf.yaml b/tests/components/cst9220/test.esp32-idf.yaml new file mode 100644 index 0000000000..984f08db47 --- /dev/null +++ b/tests/components/cst9220/test.esp32-idf.yaml @@ -0,0 +1,12 @@ +substitutions: + cs_pin: GPIO4 + dc_pin: GPIO5 + disp_reset_pin: GPIO12 + interrupt_pin: GPIO15 + reset_pin: GPIO25 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/deep_sleep/test.esp32-c5-idf.yaml b/tests/components/deep_sleep/test.esp32-c5-idf.yaml new file mode 100644 index 0000000000..11abe70711 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c5-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/deep_sleep/test.esp32-c61-idf.yaml b/tests/components/deep_sleep/test.esp32-c61-idf.yaml new file mode 100644 index 0000000000..11abe70711 --- /dev/null +++ b/tests/components/deep_sleep/test.esp32-c61-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + wakeup_pin: GPIO4 + +<<: !include common.yaml +<<: !include common-esp32-ext1.yaml diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 8a420f299a..fb43b06567 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -76,6 +76,14 @@ display: - platform: epaper_spi model: seeed-reterminal-e1002 + - platform: epaper_spi + model: seeed-reterminal-e1004 + cs_pin: 33 + cs1_pin: 34 + dc_pin: 35 + reset_pin: 36 + busy_pin: 37 + enable_pin: 39 - platform: epaper_spi model: seeed-ee04-mono-4.26 full_update_every: 10 @@ -161,3 +169,66 @@ display: busy_pin: allow_other_uses: true number: GPIO4 + + # Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-bv4 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + + # Soldered Inkplate 2 3-color e-paper (104x212, BWR) + - platform: epaper_spi + spi_id: spi_bus + model: inkplate2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + + # Waveshare 7.5" V2 BWR (800x480, UC8179 controller, EDP_7in5b_V2) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-7.5in-bv2-bwr + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0)); diff --git a/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml new file mode 100644 index 0000000000..27066710e0 --- /dev/null +++ b/tests/components/epaper_spi/validate-e1004.esp32-s3-idf.yaml @@ -0,0 +1,38 @@ +esphome: + name: e1004-test + friendly_name: E1004 Test + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + framework: + type: esp-idf + +psram: + mode: octal + +spi: + - id: epaper_spi_bus + clk_pin: GPIO7 + mosi_pin: GPIO9 + +display: + - platform: epaper_spi + spi_id: epaper_spi_bus + model: seeed-reterminal-e1004 + update_interval: never + lambda: |- + it.fill(Color::WHITE); + it.rectangle(10, 10, it.get_width() - 20, it.get_height() - 20, Color::BLACK); + it.print(it.get_width() / 2, it.get_height() / 2, id(my_font), Color::BLACK, TextAlign::CENTER, "E1004 Test"); + it.circle(100, 100, 30, Color(255, 0, 0)); + it.circle(200, 100, 30, Color(0, 255, 0)); + it.circle(300, 100, 30, Color(0, 0, 255)); + it.circle(400, 100, 30, Color(255, 255, 0)); + +font: + - file: "gfonts://Roboto" + id: my_font + size: 20 + +logger: diff --git a/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml new file mode 100644 index 0000000000..ab9001efec --- /dev/null +++ b/tests/components/esp32/test-nvs_encryption.esp32-s3-idf.yaml @@ -0,0 +1,9 @@ +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + nvs_encryption: + key_id: 0 + +<<: !include common.yaml diff --git a/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5d6ab455ac --- /dev/null +++ b/tests/components/esp32/test-ota_downgrade_protection.esp32-s3-idf.yaml @@ -0,0 +1,22 @@ +esphome: + project: + name: esphome.downgrade_test + version: "1.2.3" + +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + enable_ota_downgrade_protection: true + signed_ota_verification: + signing_key: ../../components/esp32/dummy_signing_key.pem + signing_scheme: rsa3072 + +# wifi + ota so the IDF OTA backend compiles with USE_OTA_DOWNGRADE_PROTECTION. +wifi: + ssid: MySSID + password: password1 + +ota: + - platform: esphome diff --git a/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5b57993e87 --- /dev/null +++ b/tests/components/esp32/validate-signed_ota_external.esp32-s3-idf.yaml @@ -0,0 +1,11 @@ +# Secure Boot V2 schemes carry the public key inside each image's signature +# block, so verifying externally-signed binaries needs no key in the config: +# a bare block enables verification with the default rsa3072 scheme. +esp32: + variant: esp32s3 + framework: + type: esp-idf + advanced: + signed_ota_verification: + +<<: !include common.yaml diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index f05735e8f4..ae43baa41a 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,6 +2,7 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 + max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: diff --git a/tests/components/file/common.yaml b/tests/components/file/common.yaml new file mode 100644 index 0000000000..e95c6b01f6 --- /dev/null +++ b/tests/components/file/common.yaml @@ -0,0 +1,17 @@ +image: + - platform: file + id: file_binary_image + file: ../../pnglogo.png + type: BINARY + dither: FloydSteinberg + - platform: file + id: file_rgb565_image + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel + resize: 50x50 + - platform: file + id: file_mdi_image + file: mdi:alert-circle-outline + type: BINARY + resize: 24x24 diff --git a/tests/components/file/test.esp32-idf.yaml b/tests/components/file/test.esp32-idf.yaml new file mode 100644 index 0000000000..29822d7b4f --- /dev/null +++ b/tests/components/file/test.esp32-idf.yaml @@ -0,0 +1,14 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +display: + - platform: ili9xxx + id: file_main_lcd + spi_id: spi_bus + model: ili9342 + cs_pin: 15 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + +<<: !include common.yaml diff --git a/tests/components/file/test.host.yaml b/tests/components/file/test.host.yaml new file mode 100644 index 0000000000..76f9e5af85 --- /dev/null +++ b/tests/components/file/test.host.yaml @@ -0,0 +1,9 @@ +display: + - platform: sdl + id: file_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +<<: !include common.yaml diff --git a/tests/components/gpio/test.nrf52-adafruit.yaml b/tests/components/gpio/test.nrf52-adafruit.yaml index fb3f368e03..d034736524 100644 --- a/tests/components/gpio/test.nrf52-adafruit.yaml +++ b/tests/components/gpio/test.nrf52-adafruit.yaml @@ -1,7 +1,31 @@ +# P0.2, P0.4 and P0.5 all live on the same Zephyr port device (gpio0) and each +# attaches its own interrupt. This locks in shared-port behavior: every pin owns +# a separate gpio_callback initialized with its own BIT(pin) mask, so Zephyr +# dispatches to each pin independently even though the port device is shared. binary_sensor: - platform: gpio pin: 2 id: gpio_binary_sensor + use_interrupt: true + interrupt_type: ANY + + # Inverted pin with an edge-specific interrupt: exercises the inversion-aware + # interrupt-arming path (logical RISING must arm on the physical falling edge). + - platform: gpio + pin: + number: P0.4 + inverted: true + id: gpio_binary_sensor_inverted + use_interrupt: true + interrupt_type: RISING + + # Second non-inverted interrupt on the same port (gpio0) as P0.2 above: verifies + # multiple pins sharing one port device each get their own callback/pin_mask. + - platform: gpio + pin: P0.5 + id: gpio_binary_sensor_shared_port + use_interrupt: true + interrupt_type: FALLING output: - platform: gpio diff --git a/tests/components/gsl3670/test.esp32-s3-idf.yaml b/tests/components/gsl3670/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..5c3f4b931c --- /dev/null +++ b/tests/components/gsl3670/test.esp32-s3-idf.yaml @@ -0,0 +1,34 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +xl9535: + id: expander + +display: + - platform: mipi_spi + id: gsl3670_display + spi_id: spi_bus + model: t-display-s3-pro + # The model's default DC pin (GPIO9) clashes with the shared i2c bus SCL + # pin, so override it onto a free pin for this test. + dc_pin: GPIO5 + +psram: + mode: quad + +touchscreen: + # Firmware downloaded from the model's default release URL and cached. + - platform: gsl3670 + model: seeed-reterminal-d1001 + display: gsl3670_display + interrupt_pin: 18 + # Explicit firmware URL + SHA-256 override. + - platform: gsl3670 + model: seeed-reterminal-d1001 + display: gsl3670_display + reset_pin: 10 + interrupt_pin: 11 + firmware: + url: https://github.com/esphome-libs/gsl3670-firmware/releases/download/v1.0.0/seeed-d1001-fw.bin + sha256: 2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4 diff --git a/tests/components/host/preferences_test.cpp b/tests/components/host/preferences_test.cpp new file mode 100644 index 0000000000..8e79db04f5 --- /dev/null +++ b/tests/components/host/preferences_test.cpp @@ -0,0 +1,174 @@ +#ifdef USE_HOST +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" + +namespace esphome::host::testing { +namespace fs = std::filesystem; + +/// RAII helper to save and restore an environment variable. +class ScopedEnvVar { + public: + explicit ScopedEnvVar(const char *name) : name_(name) { + const char *val = getenv(name); + if (val != nullptr) { + saved_value_ = val; + was_set_ = true; + } + } + ~ScopedEnvVar() { + if (this->was_set_) { + setenv(this->name_.c_str(), this->saved_value_.c_str(), 1); + } else { + unsetenv(this->name_.c_str()); + } + } + ScopedEnvVar(const ScopedEnvVar &) = delete; + ScopedEnvVar &operator=(const ScopedEnvVar &) = delete; + + private: + std::string name_; + std::string saved_value_; + bool was_set_{false}; +}; + +class HostPreferencesTest : public ::testing::Test { + protected: + void SetUp() override { + // Create a unique temp directory for this test + this->temp_dir_ = fs::temp_directory_path() / "esphome_prefs_test"; + fs::create_directories(this->temp_dir_); + + // Set up App name — string literal has static storage so StringRef is safe + App.pre_setup("test_prefs", 10, "", 0); + } + + void TearDown() override { + std::error_code ec; + fs::remove_all(this->temp_dir_, ec); + } + + fs::path temp_dir_; +}; + +TEST_F(HostPreferencesTest, BothVarsUnset_SyncReturnsFalse) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, BothVarsUnset_SaveSucceedsInMemory) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + unsetenv("HOME"); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + // save() stores in memory even without a valid file path + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + + // But sync to disk should fail + EXPECT_FALSE(prefs.sync()); +} + +TEST_F(HostPreferencesTest, PrefDirSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + unsetenv("HOME"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in ESPHOME_PREFDIR + auto expected_file = prefdir / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, HomeSet_SaveAndSync) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto home = this->temp_dir_ / "home"; + setenv("HOME", home.c_str(), 1); + unsetenv("ESPHOME_PREFDIR"); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // Verify file was created in HOME/.esphome/prefs + auto expected_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(expected_file)); +} + +TEST_F(HostPreferencesTest, PrefDirTakesPrecedenceOverHome) { + ScopedEnvVar home_guard("HOME"); + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "prefdir"; + auto home = this->temp_dir_ / "home"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + setenv("HOME", home.c_str(), 1); + + HostPreferences prefs; + uint32_t value = 42; + EXPECT_TRUE(prefs.save(0x1234, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + + // File should be in ESPHOME_PREFDIR, not HOME + auto prefdir_file = prefdir / "test_prefs.prefs"; + auto home_file = home / ".esphome" / "prefs" / "test_prefs.prefs"; + EXPECT_TRUE(fs::exists(prefdir_file)); + EXPECT_FALSE(fs::exists(home_file)); +} + +TEST_F(HostPreferencesTest, SaveAndLoadRoundTrip) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "roundtrip"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + // Save data with one instance + { + HostPreferences prefs; + uint32_t value = 0xDEADBEEF; + EXPECT_TRUE(prefs.save(0xABCD, reinterpret_cast(&value), sizeof(value))); + EXPECT_TRUE(prefs.sync()); + } + + // Load with a fresh instance (reads from file) + { + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_TRUE(prefs.load(0xABCD, reinterpret_cast(&loaded), sizeof(loaded))); + EXPECT_EQ(loaded, 0xDEADBEEFu); + } +} + +TEST_F(HostPreferencesTest, LoadNonExistentKeyReturnsFalse) { + ScopedEnvVar prefdir_guard("ESPHOME_PREFDIR"); + + auto prefdir = this->temp_dir_ / "nokey"; + setenv("ESPHOME_PREFDIR", prefdir.c_str(), 1); + + HostPreferences prefs; + uint32_t loaded = 0; + EXPECT_FALSE(prefs.load(0x9999, reinterpret_cast(&loaded), sizeof(loaded))); +} + +} // namespace esphome::host::testing + +#endif diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 9819068970..5a8f938319 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -1,85 +1,104 @@ image: - - id: binary_image + - platform: file + id: binary_image file: ../../pnglogo.png type: BINARY dither: FloydSteinberg - - id: transparent_transparent_image + - platform: file + id: transparent_transparent_image file: ../../pnglogo.png type: BINARY transparency: chroma_key - - id: rgba_image + - platform: file + id: rgba_image file: ../../pnglogo.png type: RGB transparency: alpha_channel resize: 50x50 - - id: rgb24_image + - platform: file + id: rgb24_image file: ../../pnglogo.png type: RGB transparency: chroma_key - - id: rgb_image + - platform: file + id: rgb_image file: ../../pnglogo.png type: RGB transparency: opaque - - id: rgb565_image + - platform: file + id: rgb565_image file: ../../pnglogo.png type: RGB565 transparency: opaque - - id: rgb565_ck_image + - platform: file + id: rgb565_ck_image file: ../../pnglogo.png type: RGB565 transparency: chroma_key - - id: rgb565_alpha_image + - platform: file + id: rgb565_alpha_image file: ../../pnglogo.png type: RGB565 transparency: alpha_channel - - id: grayscale_alpha_image + - platform: file + id: grayscale_alpha_image file: ../../pnglogo.png type: grayscale transparency: alpha_channel resize: 50x50 - - id: grayscale_ck_image + - platform: file + id: grayscale_ck_image file: ../../pnglogo.png type: grayscale transparency: chroma_key - - id: grayscale_image + - platform: file + id: grayscale_image file: ../../pnglogo.png type: grayscale transparency: opaque - - id: web_svg_image + - platform: file + id: web_svg_image file: https://media.esphome.io/logo/logo.svg resize: 256x48 type: BINARY transparency: chroma_key - - id: web_tiff_image + - platform: file + id: web_tiff_image file: https://media.esphome.io/tests/images/SIPI_Jelly_Beans_4.1.07.tiff type: RGB resize: 48x48 - - id: web_redirect_image + - platform: file + id: web_redirect_image file: https://media.esphome.io/logo/logo.png type: RGB resize: 48x48 - - id: mdi_alert + - platform: file + id: mdi_alert type: BINARY file: mdi:alert-circle-outline resize: 50x50 - - id: another_alert_icon + - platform: file + id: another_alert_icon file: mdi:alert-outline type: BINARY - - file: mdil:arrange-bring-to-front + - platform: file + file: mdil:arrange-bring-to-front id: mdil_id resize: 50x50 type: binary transparency: chroma_key - - file: mdi:beer + - platform: file + file: mdi:beer id: mdi_id resize: 50x50 type: binary transparency: chroma_key - - file: memory:alert-octagon + - platform: file + file: memory:alert-octagon id: memory_id resize: 50x50 type: binary diff --git a/tests/components/image/test.esp8266-ard.yaml b/tests/components/image/test.esp8266-ard.yaml index 492b57c449..939a3ac39b 100644 --- a/tests/components/image/test.esp8266-ard.yaml +++ b/tests/components/image/test.esp8266-ard.yaml @@ -12,12 +12,11 @@ display: invert_colors: true image: - defaults: + - platform: file + id: test_image + file: ../../pnglogo.png type: rgb565 transparency: opaque byte_order: little_endian resize: 50x50 dither: FloydSteinberg - images: - - id: test_image - file: ../../pnglogo.png diff --git a/tests/components/image/test.host.yaml b/tests/components/image/test.host.yaml index aa45497088..455d41d0c2 100644 --- a/tests/components/image/test.host.yaml +++ b/tests/components/image/test.host.yaml @@ -7,43 +7,60 @@ display: height: 480 image: - binary: - - id: binary_image - file: ../../pnglogo.png - dither: FloydSteinberg - - id: transparent_transparent_image - file: ../../pnglogo.png - transparency: chroma_key - rgb: - alpha_channel: - - id: rgba_image - file: ../../pnglogo.png - resize: 50x50 - chroma_key: - - id: rgb24_image - file: ../../pnglogo.png - type: RGB - opaque: - - id: rgb_image - file: ../../pnglogo.png - rgb565: - - id: rgb565_image - file: ../../pnglogo.png - transparency: opaque - - id: rgb565_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: rgb565_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - grayscale: - - id: grayscale_alpha_image - file: ../../pnglogo.png - transparency: alpha_channel - resize: 50x50 - - id: grayscale_ck_image - file: ../../pnglogo.png - transparency: chroma_key - - id: grayscale_image - file: ../../pnglogo.png - transparency: opaque + - platform: file + id: binary_image + file: ../../pnglogo.png + type: binary + dither: FloydSteinberg + - platform: file + id: transparent_transparent_image + file: ../../pnglogo.png + type: binary + transparency: chroma_key + - platform: file + id: rgba_image + file: ../../pnglogo.png + type: rgb + transparency: alpha_channel + resize: 50x50 + - platform: file + id: rgb24_image + file: ../../pnglogo.png + type: RGB + transparency: chroma_key + - platform: file + id: rgb_image + file: ../../pnglogo.png + type: rgb + transparency: opaque + - platform: file + id: rgb565_image + file: ../../pnglogo.png + type: rgb565 + transparency: opaque + - platform: file + id: rgb565_ck_image + file: ../../pnglogo.png + type: rgb565 + transparency: chroma_key + - platform: file + id: rgb565_alpha_image + file: ../../pnglogo.png + type: rgb565 + transparency: alpha_channel + - platform: file + id: grayscale_alpha_image + file: ../../pnglogo.png + type: grayscale + transparency: alpha_channel + resize: 50x50 + - platform: file + id: grayscale_ck_image + file: ../../pnglogo.png + type: grayscale + transparency: chroma_key + - platform: file + id: grayscale_image + file: ../../pnglogo.png + type: grayscale + transparency: opaque diff --git a/tests/components/image/validate-defaults.host.yaml b/tests/components/image/validate-defaults.host.yaml new file mode 100644 index 0000000000..16ea9e7b62 --- /dev/null +++ b/tests/components/image/validate-defaults.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` defaults/images form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path, +# including the per-type byte_order drop when an entry overrides to a non-endian +# type (binary). +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + defaults: + type: rgb565 + transparency: opaque + byte_order: little_endian + resize: 50x50 + dither: FloydSteinberg + images: + - id: legacy_defaults_image + file: ../../pnglogo.png + - id: legacy_defaults_binary + file: ../../pnglogo.png + type: binary diff --git a/tests/components/image/validate-grouped-single.host.yaml b/tests/components/image/validate-grouped-single.host.yaml new file mode 100644 index 0000000000..0b6ff3d576 --- /dev/null +++ b/tests/components/image/validate-grouped-single.host.yaml @@ -0,0 +1,24 @@ +# Legacy top-level `image:` structured form using single-dict (non-list) values +# for `images:`, a type group, and a transparency group -- the old `ensure_list` +# accepted a bare dict in each of these places. Deprecated; migrates to +# `platform: file`. Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + images: + id: legacy_images_single_dict + file: ../../pnglogo.png + type: rgb565 + rgb565: + id: legacy_grouped_type_single_dict + file: ../../pnglogo.png + rgb: + alpha_channel: + id: legacy_grouped_transparency_single_dict + file: ../../pnglogo.png diff --git a/tests/components/image/validate-grouped.host.yaml b/tests/components/image/validate-grouped.host.yaml new file mode 100644 index 0000000000..8f85aa7ca5 --- /dev/null +++ b/tests/components/image/validate-grouped.host.yaml @@ -0,0 +1,25 @@ +# Legacy top-level `image:` type-grouped form (deprecated; migrates to +# `platform: file`). Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + binary: + - id: legacy_grouped_binary + file: ../../pnglogo.png + rgb: + alpha_channel: + - id: legacy_grouped_rgba + file: ../../pnglogo.png + opaque: + - id: legacy_grouped_rgb + file: ../../pnglogo.png + rgb565: + - id: legacy_grouped_rgb565 + file: ../../pnglogo.png + transparency: chroma_key diff --git a/tests/components/image/validate-single.host.yaml b/tests/components/image/validate-single.host.yaml new file mode 100644 index 0000000000..52a945fb67 --- /dev/null +++ b/tests/components/image/validate-single.host.yaml @@ -0,0 +1,16 @@ +# Legacy top-level `image:` single-dict form (a bare image dict instead of a +# list; deprecated, migrates to `platform: file`). Config-only test exercising +# the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + id: legacy_single_image + file: ../../pnglogo.png + type: RGB565 + transparency: opaque diff --git a/tests/components/image/validate.host.yaml b/tests/components/image/validate.host.yaml new file mode 100644 index 0000000000..aa821ea7e2 --- /dev/null +++ b/tests/components/image/validate.host.yaml @@ -0,0 +1,18 @@ +# Legacy top-level `image:` list form (deprecated; migrates to `platform: file`). +# Config-only test exercising the deprecation/migration path. +display: + - platform: sdl + id: image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +image: + - id: legacy_list_binary + file: ../../pnglogo.png + type: BINARY + - id: legacy_list_rgb565 + file: ../../pnglogo.png + type: RGB565 + transparency: alpha_channel diff --git a/tests/components/it8951/test.esp32-s3-idf.yaml b/tests/components/it8951/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..c362f7f28c --- /dev/null +++ b/tests/components/it8951/test.esp32-s3-idf.yaml @@ -0,0 +1,109 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + +display: + # Generic IT8951 with explicit dimensions + - platform: it8951 + spi_id: spi_bus + model: it8951 + dimensions: + width: 1872 + height: 1404 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + enable_pin: + - GPIO17 + - GPIO18 + vcom: 1500 + update_interval: 60s + # Exercise an alias for the update_mode config option. + update_mode: fast + lambda: |- + it.circle(64, 64, 50, Color::BLACK); + + # m5stack-m5paper (960x540) — model supplies pin defaults + - platform: it8951 + id: m5epd_display + spi_id: spi_bus + model: m5stack-m5paper + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + full_update_every: 30 + invert_colors: false + sleep_when_done: true + grayscale: true + update_mode: GC16 + rotation: 270 + transform: + mirror_x: false + mirror_y: false + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + + # seeed-reterminal-e1003 (1872x1404) + - platform: it8951 + spi_id: spi_bus + model: seeed-reterminal-e1003 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + vcom: 1400 + sleep_when_done: false + lambda: |- + it.filled_rectangle(0, 0, 128, 128, Color::BLACK); + + # seeed-ee03 (1872x1404), monochrome fast path + - platform: it8951 + spi_id: spi_bus + model: seeed-ee03 + cs_pin: + allow_other_uses: true + number: GPIO5 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + grayscale: false + dithering: false + update_mode: DU + lambda: |- + it.circle(128, 128, 64, Color::BLACK); + +# Exercise the it8951.update automation: alias modes, a direct enum-name mode, +# and the bare (default-mode) form. +interval: + - interval: 30s + then: + - it8951.update: + id: m5epd_display + mode: fast + - it8951.update: + id: m5epd_display + mode: full + - it8951.update: + id: m5epd_display + mode: A2 + - it8951.update: m5epd_display diff --git a/tests/components/ld2450/common.h b/tests/components/ld2450/common.h index 304634edca..de912ddcbc 100644 --- a/tests/components/ld2450/common.h +++ b/tests/components/ld2450/common.h @@ -18,6 +18,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // USE_ESP8266 || USE_ESP32 }; // Expose protected members for testing. diff --git a/tests/components/lsm6ds/common.yaml b/tests/components/lsm6ds/common.yaml index 832254781f..aeacd31448 100644 --- a/tests/components/lsm6ds/common.yaml +++ b/tests/components/lsm6ds/common.yaml @@ -1,54 +1,66 @@ sensor: - platform: lsm6ds - name: "lsm6ds Temperature" + name: "LSM6DS Temperature" - platform: motion + motion_id: lsm6ds_motion type: acceleration_x - name: "Accel X" + name: "LSM6DS Accel X" accuracy_decimals: 4 filters: - sliding_window_moving_average: window_size: 4 send_every: 1 - platform: motion + motion_id: lsm6ds_motion type: acceleration_y - name: "Accel Y" + name: "LSM6DS Accel Y" accuracy_decimals: 4 - platform: motion + motion_id: lsm6ds_motion type: acceleration_z - name: "Accel Z" + name: "LSM6DS Accel Z" accuracy_decimals: 4 # Gyroscope axes (unit: °/s) - platform: motion + motion_id: lsm6ds_motion type: gyroscope_x - name: "Gyro X" + name: "LSM6DS Gyro X" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_y - name: "Gyro Y" + name: "LSM6DS Gyro Y" - platform: motion + motion_id: lsm6ds_motion type: gyroscope_z - name: "Gyro Z" + name: "LSM6DS Gyro Z" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_x - name: "Angular Rate X" + name: "LSM6DS Angular Rate X" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_y - name: "Angular Rate Y" + name: "LSM6DS Angular Rate Y" - platform: motion + motion_id: lsm6ds_motion type: angular_rate_z - name: "Angular Rate Z" + name: "LSM6DS Angular Rate Z" - platform: motion + motion_id: lsm6ds_motion type: pitch - name: "Pitch" + name: "LSM6DS Pitch" - platform: motion + motion_id: lsm6ds_motion type: roll - name: "Roll" + name: "LSM6DS Roll" motion: - platform: lsm6ds + id: lsm6ds_motion # Accelerometer full-scale range: 2G | 4G | 8G | 16G accelerometer_range: 4G diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index f500002f40..b4d5fe0387 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -91,8 +91,8 @@ binary_sensor: animation: move_right time: 600ms - platform: lvgl - id: button_checker - name: LVGL button + id: common_button_checker + name: Common button widget: spin_up on_state: then: diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 7af058e6b8..f085b62cb6 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -24,20 +24,6 @@ binary_sensor: name: Button A checked widget: button_a state: checked - - platform: lvgl - id: button_checker - name: LVGL button - widget: button_button - state: checked - on_state: - then: - - lvgl.checkbox.update: - id: checkbox_id - state: - checked: !lambda |- - auto y = x; // block inlining of one line return - return y; - - platform: lvgl id: button_presser name: Button pressed @@ -49,6 +35,9 @@ lvgl: rotation: 90 log_level: debug resume_on_input: true + paused: true + update_when_display_idle: true + refresh_interval: 30ms on_pause: - logger.log: LVGL is Paused - lvgl.display.set_rotation: 90 @@ -57,6 +46,40 @@ lvgl: - lvgl.display.set_rotation: rotation: 0 lvgl_id: lvgl_id + - lvgl.display.set_rotation: + rotation: !lambda "return 180;" + lvgl_id: lvgl_id + on_landscape: + - logger.log: LVGL display is now landscape + # Re-layout a container in response to orientation changes. The layout type + # and grid structure are fixed at creation; only the style options change. + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: center + grid_row_align: space_between + pad_row: 4px + - lvgl.update: + top_layer: + layout: + flex_flow: row + on_portrait: + - logger.log: LVGL display is now portrait + - lvgl.widget.update: + id: grid_rows_only_shorthand + layout: + grid_column_align: start + pad_row: 2px + # Top-level layout applies to the active screen + - lvgl.update: + layout: + flex_flow: column + pad_row: 8px + - lvgl.update: + top_layer: + layout: + flex_flow: column + flex_align_main: center on_boot: - logger.log: LVGL has started @@ -64,6 +87,12 @@ lvgl: id: meter_arc_indicator start_value: 0 end_value: 180 + - lvgl.animation.start: + id: + - anim_slide + - anim_color + duration: 3s + loop: true on_invalidate_area: logger.log: Invalidate area on_resolution_change: @@ -108,6 +137,52 @@ lvgl: - obj: bg_color: 0x000000 bg_opa: cover + top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 50 + height: 50 + bg_color: 0xFF0000 + - label: + id: anim_label + text: anim + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: ease_in_out + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: 100 + y: + from: 0 + to: !lambda "return 80;" + opa: + from: 50% + to: 100% + - id: anim_color + duration: 2s + timing: + - round_trip + - type: gravity + bounce: 0.3 + acceleration: 0.8 + widgets: + - id: anim_label + text_color: + from: 0xFF0000 + to: color_id theme: dark_mode: true obj: @@ -210,6 +285,11 @@ lvgl: on_click: then: - lvgl.display.set_rotation: 0 + - lvgl.animation.stop: anim_slide + - lvgl.animation.stop: + id: + - anim_slide + - anim_color - lvgl.widget.hide: message_box - lvgl.style.update: id: style_test @@ -217,6 +297,9 @@ lvgl: bg_opa: !lambda return 0.5; - lvgl.image.update: id: lv_image + src: + mapping: image_map + value: !lambda return round(1.0); scale: !lambda return 512; rotation: !lambda return 100; pivot_x: !lambda return 20; @@ -342,9 +425,16 @@ lvgl: text_font: montserrat_40 border_post: true on_press: - lvgl.label.update: - id: hello_label - text: Goodbye + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: !lambda return 2; + - lvgl.label.update: + id: hello_label + text: + mapping: lvgl_string_map + value: 2 on_click: then: - lvgl.animimg.stop: anim_img @@ -1450,6 +1540,21 @@ image: invert_alpha: true transparency: alpha_channel +mapping: + - id: image_map + from: int + to: image + entries: + 0: cat_image + 1: dog_image + - id: lvgl_string_map + from: int + to: string + entries: + 0: "First" + 1: "Second" + 2: "Third" + color: - id: light_blue hex: "3340FF" diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 79ea06f16a..d938017fd9 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -1,7 +1,8 @@ packages: - lvgl: !include lvgl-package.yaml + lvgl_package: !include lvgl-package.yaml spi: !include ../../test_build_components/common/spi/esp32-idf.yaml i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + lvgl: !include common.yaml sensor: - platform: rotary_encoder @@ -77,5 +78,3 @@ lvgl: - component.update: tft_display - delay: 60s - lvgl.resume: - -<<: !include common.yaml diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 6328648fe3..90cbb3c0a5 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -22,6 +22,36 @@ lvgl: displays: sdl0 rotation: 180 top_layer: + widgets: + - obj: + id: anim_box + x: 0 + y: 0 + width: 40 + height: 40 + bg_color: 0xFF0000 + animations: + - id: anim_slide + duration: 1s + start_delay: 100ms + auto_start: true + loop: true + timing: + - round_trip + - type: ease_in_out + weight: 3 + on_start: + - logger.log: anim started + on_stop: + - logger.log: anim stopped + widgets: + - id: anim_box + x: + from: 0 + to: !lambda "return 100;" + opa: + from: 50% + to: 100% - id: lvgl_1 displays: sdl1 @@ -42,7 +72,14 @@ lvgl: - label: text: Click ME on_click: - logger.log: Clicked + then: + - logger.log: Clicked + - lvgl.animation.stop: + id: anim_slide + lvgl_id: lvgl_0 + - lvgl.animation.start: + id: anim_slide + lvgl_id: lvgl_0 font: - file: "gfonts://Roboto" diff --git a/tests/components/md5/__init__.py b/tests/components/md5/__init__.py new file mode 100644 index 0000000000..cf4ad47363 --- /dev/null +++ b/tests/components/md5/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # md5's to_code calls cg.add_define("USE_MD5"), which gates md5.h. C++ unit + # test builds that pull md5 in transitively (e.g. ota's host backend, which + # has an md5::MD5Digest member) need that define, otherwise md5.h compiles to + # nothing and the dependent headers fail to find md5::MD5Digest. + manifest.enable_codegen() diff --git a/tests/components/mdns/test-fallback.esp32-idf.yaml b/tests/components/mdns/test-fallback.esp32-idf.yaml new file mode 100644 index 0000000000..b51dbb443f --- /dev/null +++ b/tests/components/mdns/test-fallback.esp32-idf.yaml @@ -0,0 +1,7 @@ +# No api, web_server or extra services so the fallback _http service +# (with version, mac and config_hash TXT records) is compiled. +wifi: + ssid: MySSID + password: password1 + +mdns: diff --git a/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml new file mode 100644 index 0000000000..23f3abdeb2 --- /dev/null +++ b/tests/components/mdns/test-webserver-no-api.esp32-idf.yaml @@ -0,0 +1,9 @@ +# web_server without the native api so the version, mac and config_hash +# TXT records are attached to the web_server _http service. +wifi: + ssid: MySSID + password: password1 + +web_server: + +mdns: diff --git a/tests/components/mdns/test.nrf52-adafruit.yaml b/tests/components/mdns/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..c24d0a1908 --- /dev/null +++ b/tests/components/mdns/test.nrf52-adafruit.yaml @@ -0,0 +1,7 @@ +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 + +mdns: diff --git a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml index 399c25c1d0..12b45ee160 100644 --- a/tests/components/mipi_rgb/test.esp32-s3-idf.yaml +++ b/tests/components/mipi_rgb/test.esp32-s3-idf.yaml @@ -1,7 +1,11 @@ packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml + i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml psram: mode: octal -<<: !include common.yaml +ch422g: + +display: + - platform: mipi_rgb + model: WAVESHARE-5-1024X600 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index 798f7283f6..45f7b65289 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -37,6 +37,9 @@ class MockUARTComponent : public uart::UARTComponent { MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) }; class TestableMitsubishiCN105 : public MitsubishiCN105 { diff --git a/tests/components/modbus/heap_probe_test.cpp b/tests/components/modbus/heap_probe_test.cpp new file mode 100644 index 0000000000..af43c6e5e3 --- /dev/null +++ b/tests/components/modbus/heap_probe_test.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/modbus/modbus.h" + +// The allocation counters rely on AddressSanitizer's malloc hooks. The cpp_unit_test harness always +// builds with ASan, so this is exercised in CI; the fallback only applies to out-of-harness builds. +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define HEAP_PROBE_HAS_ASAN +#endif + +#ifdef HEAP_PROBE_HAS_ASAN + +// Allocation counters fed by ASan's malloc hooks; sampled tightly around the calls under test. +static std::atomic g_alloc_count{0}; +static std::atomic g_alloc_bytes{0}; + +static void malloc_hook(const volatile void *, size_t size) { + g_alloc_count++; + g_alloc_bytes += size; +} +static void free_hook(const volatile void *) {} + +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); + +[[maybe_unused]] static const int g_hooks_installed = __sanitizer_install_malloc_and_free_hooks(malloc_hook, free_hook); + +namespace esphome::modbus::testing { + +namespace { + +struct Sample { + size_t count; + size_t bytes; +}; + +template Sample sample(F &&f) { + size_t c0 = g_alloc_count.load(), b0 = g_alloc_bytes.load(); + f(); + return {g_alloc_count.load() - c0, g_alloc_bytes.load() - b0}; +} + +} // namespace + +// Typical frames (reads and single-register/coil writes are exactly address + 5-byte PDU + CRC = 8 +// bytes) fit the SmallInlineBuffer and are built with zero heap allocations; only larger frames spill +// to a single allocation. +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // 5 bytes -> 8-byte frame, inline + Sample typical = sample([&] { + ModbusFrame frame(0x02, read_pdu, sizeof(read_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_typical count=%zu bytes=%zu\n", typical.count, typical.bytes); + EXPECT_EQ(typical.count, 0u); + + uint8_t large_pdu[250] = {0x10}; // multi-register write -> 253-byte frame, spills once + Sample large = sample([&] { + ModbusFrame frame(0x02, large_pdu, sizeof(large_pdu)); + (void) frame; + }); + printf("HEAPPROBE frame_large count=%zu bytes=%zu\n", large.count, large.bytes); + EXPECT_EQ(large.count, 1u); +} + +// Queueing typical commands is fully allocation-free: the frame fits the inline buffer and the tx +// deque's first block is already allocated when the hub is constructed. (A queue deeper than one +// deque block - roughly a dozen commands - would allocate further blocks.) +TEST(HeapProbe, QueueingTypicalCommandsIsAllocationFree) { + ModbusClientHub hub; + ModbusClientDevice device(&hub, 0x02); + + StaticVector req; + const uint8_t read_pdu[] = {0x03, 0x01, 0x00, 0x00, 0x02}; + req.assign(read_pdu, read_pdu + sizeof(read_pdu)); + + constexpr int n = 12; + size_t total = 0; + for (int i = 0; i != n; i++) { + total += sample([&] { device.send_pdu(req); }).count; + } + printf("HEAPPROBE queue_%d_typical_commands total_allocs=%zu\n", n, total); + EXPECT_EQ(total, 0u); +} + +} // namespace esphome::modbus::testing + +#else // !HEAP_PROBE_HAS_ASAN + +namespace esphome::modbus::testing { +TEST(HeapProbe, TypicalFrameConstructionIsAllocationFree) { + GTEST_SKIP() << "allocation counting requires an AddressSanitizer build"; +} +} // namespace esphome::modbus::testing + +#endif // HEAP_PROBE_HAS_ASAN diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp new file mode 100644 index 0000000000..d04c4fe10c --- /dev/null +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -0,0 +1,178 @@ +#include + +#include +#include + +#include "esphome/components/modbus/modbus.h" + +namespace esphome::modbus::testing { + +namespace { + +// Exposes the protected tx queue and waiting-for-response slot so tests can drive the +// no-response path without a UART: force_send_front() mimics send_next_frame_() moving the +// front frame in flight, timeout_waiting() mimics the loop() no-response timeout handling. +class NoResponseProbeHub : public ModbusClientHub { + public: + size_t queued_frames() const { return this->tx_buffer_.size(); } + const ModbusDeviceCommand &front() const { return this->tx_buffer_.front(); } + bool waiting() const { return this->waiting_for_response_.has_value(); } + const ModbusDeviceCommand &waiting_command() const { + EXPECT_TRUE(this->waiting_for_response_.has_value()); + return *this->waiting_for_response_; // NOLINT(bugprone-unchecked-optional-access) + } + + void force_send_front() { + this->waiting_for_response_ = std::move(this->tx_buffer_.front()); + this->tx_buffer_.pop_front(); + } + // Drives the real unexpected-frame branch in process_modbus_server_frame(). + void receive_frame_for_test(uint8_t address, uint8_t function_code, const uint8_t *data, uint16_t len) { + this->process_modbus_server_frame(address, function_code, data, len); + } + void timeout_waiting() { + if (this->waiting_for_response_.has_value()) + this->notify_no_response_(*this->waiting_for_response_); + this->waiting_for_response_.reset(); + } +}; + +// A device with a scripted answer to on_modbus_no_response(). +class RetryingDevice : public ModbusClientDevice { + public: + RetryingDevice(ModbusClientHub *hub, uint8_t address, bool retry) : ModbusClientDevice(hub, address), retry_(retry) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + return this->retry_; + } + int no_response_count_{0}; + + protected: + bool retry_{false}; +}; + +// A device that clears its own queued traffic from inside the no-response callback, then asks for a retry. +class ClearingRetryDevice : public ModbusClientDevice { + public: + ClearingRetryDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + bool on_modbus_no_response() override { + this->no_response_count_++; + this->clear_tx_queue_for_device(); // detaches this device from the waiting slot mid-callback + return true; // and still requests a retry + } + int no_response_count_{0}; +}; + +constexpr uint8_t READ_PDU[] = {0x03, 0x01, 0x00, 0x00, 0x02}; // read 2 holding registers at 0x100 + +StaticVector read_pdu() { + StaticVector pdu; + pdu.assign(READ_PDU, READ_PDU + sizeof(READ_PDU)); + return pdu; +} + +} // namespace + +// A device that requests a retry gets the frame the hub was holding re-queued on its behalf, +// byte-identical and still routed to the same device. +TEST(ModbusClientHubNoResponse, RetryRequeuesWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + ASSERT_EQ(hub.queued_frames(), 1u); + hub.force_send_front(); + ASSERT_EQ(hub.queued_frames(), 0u); + ASSERT_TRUE(hub.waiting()); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + ASSERT_EQ(hub.queued_frames(), 1u); + const ModbusDeviceCommand &requeued = hub.front(); + EXPECT_EQ(requeued.device, &device); + // address + PDU + CRC + ASSERT_EQ(requeued.frame.size(), sizeof(READ_PDU) + 3); + EXPECT_EQ(requeued.frame.data.data()[0], 0x02); + EXPECT_EQ(0, memcmp(requeued.frame.data.data() + 1, READ_PDU, sizeof(READ_PDU))); +} + +// A device that declines the retry has the frame dropped. +TEST(ModbusClientHubNoResponse, NoRetryDropsWaitingFrame) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/false); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// After the device is detached from the waiting frame (e.g. clear_tx_queue_for_device on +// destruction), a timeout must not deliver a callback or re-queue anything. +TEST(ModbusClientHubNoResponse, DetachedDeviceIsNotNotified) { + NoResponseProbeHub hub; + { + RetryingDevice device(&hub, 0x02, /*retry=*/true); + device.send_pdu(read_pdu()); + hub.force_send_front(); + // device destructor clears its queue entries, including the waiting frame's device pointer + } + ASSERT_TRUE(hub.waiting()); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + hub.timeout_waiting(); + + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.queued_frames(), 0u); +} + +// An unexpected frame interrupts the transaction: the retry is re-queued immediately, but the +// waiting entry survives as an interrupted shell (device detached) that keeps tx blocked until the +// send-wait timeout clears it - without a second no-response callback or a duplicate requeue. +TEST(ModbusClientHubNoResponse, RetryBehindInterruptedShell) { + NoResponseProbeHub hub; + RetryingDevice device(&hub, 0x02, /*retry=*/true); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + + // A frame from the wrong address (0x07, expected 0x02) hits the unexpected-frame branch. + const uint8_t stray_payload[] = {0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x07, 0x03, stray_payload, sizeof(stray_payload)); + + EXPECT_EQ(device.no_response_count_, 1); + ASSERT_EQ(hub.queued_frames(), 1u); // exactly one requeue... + EXPECT_EQ(hub.front().device, &device); + ASSERT_TRUE(hub.waiting()); // ...while the shell stays in the waiting slot + EXPECT_TRUE(hub.waiting_command().interrupted); + EXPECT_EQ(hub.waiting_command().device, nullptr); + + // The send-wait timeout clears the shell without a second callback or another requeue. + hub.timeout_waiting(); + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 1u); +} + +// A callback that detaches the device (clear_tx_queue_for_device()) wins over its own retry request: +// no orphaned frame with a null device is re-queued. +TEST(ModbusClientHubNoResponse, MidCallbackClearCancelsRetry) { + NoResponseProbeHub hub; + ClearingRetryDevice device(&hub, 0x02); + + device.send_pdu(read_pdu()); + hub.force_send_front(); + hub.timeout_waiting(); + + EXPECT_EQ(device.no_response_count_, 1); + EXPECT_EQ(hub.queued_frames(), 0u); // the retry was not re-queued for a detached device + EXPECT_FALSE(hub.waiting()); +} + +} // namespace esphome::modbus::testing diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index e1b4fb2aa6..1c57a81e6f 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -4,19 +4,229 @@ 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); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_WORD, 2, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberRejectsTruncatedMultiRegisterValue) { const std::vector data{0x12, 0x34, 0x56}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_DWORD, 0, 0xFFFFFFFF), 0); + EXPECT_FALSE(payload_to_number(std::span(data), SensorValueType::U_DWORD, 0, 0xFFFFFFFF).has_value()); } TEST(ModbusHelpersTest, PayloadToNumberDecodesValidWord) { const std::vector data{0x12, 0x34}; - EXPECT_EQ(payload_to_number(data, SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); + EXPECT_EQ(payload_to_number(std::span(data), SensorValueType::U_WORD, 0, 0xFFFFFFFF), 0x1234); +} + +// --- registers_to_number --------------------------------------------------- +// Register words are host byte order; results must match the byte-based payload_to_number. + +TEST(ModbusHelpersTest, RegistersToNumberDecodesWord) { + const uint16_t registers[] = {0x1234}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesDwordHighWordFirst) { + const uint16_t registers[] = {0x1234, 0x5678}; + EXPECT_EQ(registers_to_number(registers, 2, SensorValueType::U_DWORD), 0x12345678); +} + +TEST(ModbusHelpersTest, RegistersToNumberDecodesAtSpanStart) { + // The function decodes the value at the start of the span; the caller advances the pointer. + const uint16_t registers[] = {0xAAAA, 0x1234}; + EXPECT_EQ(registers_to_number(registers + 1, 1, SensorValueType::U_WORD), 0x1234); +} + +TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { + // Same value via both decoders: registers (host order) vs big-endian bytes. + const uint16_t registers[] = {0x8001, 0x0002}; + const std::vector bytes{0x80, 0x01, 0x00, 0x02}; + for (auto value_type : {SensorValueType::S_DWORD, SensorValueType::U_DWORD, SensorValueType::S_DWORD_R}) { + EXPECT_EQ(registers_to_number(registers, 2, value_type), + payload_to_number(std::span(bytes), value_type, 0, 0xFFFFFFFF)) + << "value_type=" << static_cast(value_type); + } +} + +TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { + const uint16_t registers[] = {0x1234}; + EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } } // namespace esphome::modbus::helpers 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/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 2e4a81a1aa..8b2316b6e3 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -18,6 +18,7 @@ modbus_server: registers: - address: 0x9 value_type: S_DWORD + allow_partial_read: true read_lambda: |- return 31; write_lambda: |- diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp new file mode 100644 index 0000000000..d95bb473c9 --- /dev/null +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -0,0 +1,285 @@ +#include + +#include "esphome/components/modbus_server/modbus_server.h" + +namespace esphome::modbus_server { + +using modbus::ModbusExceptionCode; +using modbus::RegisterValues; + +namespace { + +RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +} // namespace + +// A single writable WORD register is applied and the handler reports success (nullopt). +TEST(ModbusServerWrite, SingleWordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + EXPECT_FALSE(status.has_value()); // nullopt == success + EXPECT_EQ(written, 0x1234); +} + +// A multi-register value is decoded high word first and applied as a single number. +TEST(ModbusServerWrite, DwordSucceeds) { + ModbusServer server; + int64_t written = -1; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.write_lambda = [&written](int64_t value) { + written = value; + return true; + }; + server.add_server_register(®); + + auto status = server.on_write_registers(0x0000, make_registers({0x1234, 0x5678})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(written, 0x12345678); +} + +// Regression: a request that under-supplies a multi-register value is rejected before any +// write_lambda runs, so no register is partially written. +TEST(ModbusServerWrite, UnderSuppliedValueAppliesNothing) { + ModbusServer server; + bool word_written = false; + ServerRegister word_reg(0x0000, SensorValueType::U_WORD, 1); + word_reg.write_lambda = [&word_written](int64_t) { + word_written = true; + return true; + }; + bool dword_written = false; + ServerRegister dword_reg(0x0001, SensorValueType::U_DWORD, 2); // needs two registers + dword_reg.write_lambda = [&dword_written](int64_t) { + dword_written = true; + return true; + }; + server.add_server_register(&word_reg); + server.add_server_register(&dword_reg); + + // Two words supplied: one for the WORD at 0x0000, but only one of the two the DWORD at 0x0001 needs. + auto status = server.on_write_registers(0x0000, make_registers({0x1111, 0x2222})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_VALUE); + EXPECT_FALSE(word_written); // the writable WORD must NOT have been applied + EXPECT_FALSE(dword_written); +} + +// A read-only register (no write_lambda) yields ILLEGAL_DATA_ADDRESS and applies nothing. +TEST(ModbusServerWrite, UnwritableRegisterRejected) { + ModbusServer server; + ServerRegister read_only(0x0000, SensorValueType::U_WORD, 1); // no write_lambda set + server.add_server_register(&read_only); + + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerWrite, UnmatchedAddressRejected) { + ModbusServer server; + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A write_lambda failing at runtime is the one non-atomic case: the earlier register is already +// applied, and the handler reports SERVICE_DEVICE_FAILURE. +TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerRegister first(0x0000, SensorValueType::U_WORD, 1); + first.write_lambda = [&first_written](int64_t) { + first_written = true; + return true; + }; + ServerRegister second(0x0001, SensorValueType::U_WORD, 1); + second.write_lambda = [](int64_t) { return false; }; // rejects at runtime + server.add_server_register(&first); + server.add_server_register(&second); + + auto status = server.on_write_registers(0x0000, make_registers({0xAAAA, 0xBBBB})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); // pre-validation passed, so the first write applied before the failure +} + +// --- on_read_registers -------------------------------------------------- + +TEST(ModbusServerRead, SingleWordSucceeds) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +TEST(ModbusServerRead, DwordReturnsTwoWordsHighFirst) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0x1234); + EXPECT_EQ(out[1], 0x5678); +} + +// Starting inside a multi-register value is rejected with ILLEGAL_DATA_ADDRESS -- not masked by the courtesy +// default -- and the read_lambda is never invoked. +TEST(ModbusServerRead, StartInsideValueRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); // occupies 0x0010 and 0x0011 + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0011, 1, out); // the second cell of the DWORD + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A read that stops short of a value's end clips it -> ILLEGAL_DATA_ADDRESS, and the read_lambda is not invoked. +TEST(ModbusServerRead, ClippedTailRejected) { + ModbusServer server; + bool read_called = false; + ServerRegister reg(0x0000, SensorValueType::U_DWORD, 2); + reg.read_lambda = [&read_called]() -> int64_t { + read_called = true; + return 0; + }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); // only 1 of the DWORD's 2 registers + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(read_called); +} + +// A write-only register (no read_lambda) is not readable -> ILLEGAL_DATA_ADDRESS, not a courtesy default. +TEST(ModbusServerRead, WriteOnlyRegisterRejected) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); // no read_lambda set + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// An unregistered address with courtesy enabled returns the default value for each cell. +TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { + ModbusServer server; + server.set_server_courtesy_response( + ServerCourtesyResponse{.enabled = true, .register_last_address = 0xFFFF, .register_value = 0xABCD}); + + RegisterValues out; + auto status = server.on_read_registers(0x0005, 2, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(out[0], 0xABCD); + EXPECT_EQ(out[1], 0xABCD); +} + +// An unregistered address with courtesy disabled is rejected. +TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { + ModbusServer server; + RegisterValues out; + auto status = server.on_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ModbusExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// --- partial reads (opt-in) ---------------------------------------------------- + +// With allow_partial_read, reading only the first register of a DWORD returns its high word. +TEST(ModbusServerRead, PartialReadHighWord) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0010, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x1234); +} + +// With allow_partial_read, starting at the interior cell returns the low word. +TEST(ModbusServerRead, PartialReadLowWordFromInterior) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0011, 1, out); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], 0x5678); +} + +// Slicing is in wire order, so a reversed value type partials correctly: U_DWORD_R emits the low word +// first, so 0x0010 holds 0x5678 and 0x0011 holds 0x1234. +TEST(ModbusServerRead, PartialReadReversedType) { + ModbusServer server; + ServerRegister reg(0x0010, SensorValueType::U_DWORD_R, 2); + reg.allow_partial_read = true; + reg.read_lambda = []() -> int64_t { return 0x12345678; }; + server.add_server_register(®); + + RegisterValues first; + ASSERT_FALSE(server.on_read_registers(0x0010, 1, first).has_value()); + ASSERT_EQ(first.size(), 1u); + EXPECT_EQ(first[0], 0x5678); + + RegisterValues second; + ASSERT_FALSE(server.on_read_registers(0x0011, 1, second).has_value()); + ASSERT_EQ(second.size(), 1u); + EXPECT_EQ(second[0], 0x1234); +} + +} // namespace esphome::modbus_server diff --git a/tests/components/mqtt/test.ln882x-ard.yaml b/tests/components/mqtt/test.ln882x-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/mqtt/test.ln882x-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/network/test.nrf52-adafruit.yaml b/tests/components/network/test.nrf52-adafruit.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-adafruit.yaml +++ b/tests/components/network/test.nrf52-adafruit.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-mcumgr.yaml b/tests/components/network/test.nrf52-mcumgr.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-mcumgr.yaml +++ b/tests/components/network/test.nrf52-mcumgr.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/network/test.nrf52-xiao-ble.yaml b/tests/components/network/test.nrf52-xiao-ble.yaml index 61889b0361..ac2fe63739 100644 --- a/tests/components/network/test.nrf52-xiao-ble.yaml +++ b/tests/components/network/test.nrf52-xiao-ble.yaml @@ -1 +1,5 @@ network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index 3ae48b2a5f..5fa0d6e88f 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -19,5 +19,3 @@ nrf52: reg0: voltage: 2.1V uicr_erase: true - framework: - version: "2.6.1-b" diff --git a/tests/components/nrf52/test.nrf52-microbit.yaml b/tests/components/nrf52/test.nrf52-microbit.yaml new file mode 100644 index 0000000000..d27f9ff699 --- /dev/null +++ b/tests/components/nrf52/test.nrf52-microbit.yaml @@ -0,0 +1 @@ +nrf52: diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index de4c0c6e00..e1b5f088bb 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -2,3 +2,5 @@ nrf52: dfu: true reg0: voltage: 1.8V + framework: + libc_nano: false diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index fc3cc94217..f71cf63de9 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -2,11 +2,9 @@ wifi: ssid: MySSID password: password1 -# Purposely test that `online_image:` does auto-load `image:` -# Keep the `image:` undefined. -# image: -online_image: - - id: online_binary_image +image: + - platform: online_image + id: online_binary_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: BINARY @@ -21,34 +19,41 @@ online_image: } else { ESP_LOGD("online_image", "Cache miss: fresh download"); } - - id: online_binary_transparent_image + - platform: online_image + id: online_binary_transparent_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png type: BINARY transparency: chroma_key format: png - - id: online_rgba_image + - platform: online_image + id: online_rgba_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: alpha_channel - - id: online_rgb24_image + - platform: online_image + id: online_rgb24_image url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png format: PNG type: RGB transparency: chroma_key - - id: online_binary_bmp + - platform: online_image + id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: BINARY - - id: online_rgb_bmp_8bit + - platform: online_image + id: online_rgb_bmp_8bit url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp format: BMP type: RGB - - id: online_jpeg_image + - platform: online_image + id: online_jpeg_image url: http://www.faqs.org/images/library.jpg format: JPEG type: RGB - - id: online_jpg_image + - platform: online_image + id: online_jpg_image url: http://www.faqs.org/images/library.jpg format: JPG type: RGB565 diff --git a/tests/components/online_image/validate.host.yaml b/tests/components/online_image/validate.host.yaml new file mode 100644 index 0000000000..f0ba98c65d --- /dev/null +++ b/tests/components/online_image/validate.host.yaml @@ -0,0 +1,22 @@ +# Legacy top-level `online_image:` form (deprecated; migrates to +# `platform: online_image`). Config-only test exercising the deprecation path. +wifi: + ssid: MySSID + password: password1 + +http_request: + +display: + - platform: sdl + id: online_image_display + auto_clear_enabled: false + dimensions: + width: 480 + height: 480 + +online_image: + - id: legacy_online_image + url: http://www.example.org/example.png + format: PNG + type: RGB565 + resize: 50x50 diff --git a/tests/components/openthread/common.yaml b/tests/components/openthread/common.yaml new file mode 100644 index 0000000000..d9eeab89ea --- /dev/null +++ b/tests/components/openthread/common.yaml @@ -0,0 +1,2 @@ +network: + enable_ipv6: true diff --git a/tests/components/openthread/test-tlv.esp32-c6-idf.yaml b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml new file mode 100644 index 0000000000..c61efd4d3c --- /dev/null +++ b/tests/components/openthread/test-tlv.esp32-c6-idf.yaml @@ -0,0 +1,20 @@ +<<: !include common.yaml + +openthread: + device_type: MTD + force_dataset: false + use_address: open-thread-test.local + tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8 + poll_period: 5s + +switch: + - platform: template + name: "Radio Always On" + optimistic: true + restore_mode: ALWAYS_OFF + turn_on_action: + then: + - openthread.set_poll_period: 0s + turn_off_action: + then: + - openthread.set_poll_period: 5s diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 008edd5397..92d120e5d1 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -1,14 +1,6 @@ -esp32: - board: esp32-c6-devkitc-1 - framework: - type: esp-idf - log_level: DEBUG - -network: - enable_ipv6: true +<<: !include common.yaml openthread: - device_type: MTD channel: 13 network_name: OpenThread-8f28 network_key: 0xdfd34f0f05cad978ec4e32b0413038ff @@ -16,7 +8,4 @@ openthread: ext_pan_id: 0xd63e8e3e495ebbc3 pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 - force_dataset: true - use_address: open-thread-test.local - poll_period: 20sec output_power: 1dBm 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/ota/test_version_compare.cpp b/tests/components/ota/test_version_compare.cpp new file mode 100644 index 0000000000..4072a45792 --- /dev/null +++ b/tests/components/ota/test_version_compare.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/ota/ota_backend.h" + +namespace esphome::ota::testing { + +// version_is_older(candidate, reference) == true means candidate is a downgrade +// and should be rejected. + +TEST(VersionIsOlder, PatchOlder) { + EXPECT_TRUE(version_is_older("1.2.3", "1.2.4")); + EXPECT_FALSE(version_is_older("1.2.4", "1.2.3")); +} + +TEST(VersionIsOlder, NumericNotLexical) { + // "1.10.0" is newer than "1.9.0" even though '1' < '9' lexically. + EXPECT_TRUE(version_is_older("1.9.0", "1.10.0")); + EXPECT_FALSE(version_is_older("1.10.0", "1.9.0")); +} + +TEST(VersionIsOlder, MajorMinor) { + EXPECT_TRUE(version_is_older("1.9.9", "2.0.0")); + EXPECT_TRUE(version_is_older("1.2.9", "1.3.0")); + EXPECT_FALSE(version_is_older("2.0.0", "1.9.9")); +} + +TEST(VersionIsOlder, EqualVersionsAllowed) { + // Re-flashing the same version must be permitted. + EXPECT_FALSE(version_is_older("1.2.3", "1.2.3")); + EXPECT_FALSE(version_is_older("2024.1.0", "2024.1.0")); +} + +TEST(VersionIsOlder, DifferingComponentCounts) { + // Missing trailing components count as 0. + EXPECT_FALSE(version_is_older("1.2", "1.2.0")); + EXPECT_FALSE(version_is_older("1.2.0", "1.2")); + EXPECT_TRUE(version_is_older("1.2", "1.2.1")); + EXPECT_FALSE(version_is_older("1.2.1", "1.2")); +} + +TEST(VersionIsOlder, CalendarVersions) { + EXPECT_TRUE(version_is_older("2024.12.0", "2025.1.0")); + EXPECT_FALSE(version_is_older("2025.1.0", "2024.12.0")); +} + +TEST(VersionIsOlder, NullInputsAreSafe) { + EXPECT_FALSE(version_is_older(nullptr, "1.2.3")); + EXPECT_FALSE(version_is_older("1.2.3", nullptr)); + EXPECT_FALSE(version_is_older(nullptr, nullptr)); +} + +} // namespace esphome::ota::testing diff --git a/tests/components/pcm5122/common.yaml b/tests/components/pcm5122/common.yaml index cf96f57464..a8ae1e6975 100644 --- a/tests/components/pcm5122/common.yaml +++ b/tests/components/pcm5122/common.yaml @@ -4,6 +4,11 @@ audio_dac: i2c_id: i2c_bus address: 0x4D bits_per_sample: 32bit + analog_gain: -6db + channel_mix: swapped + volume_min_db: -60dB + volume_max_db: -3dB + enable_pin: GPIO12 output: - platform: gpio @@ -22,3 +27,9 @@ binary_sensor: number: 4 mode: input: true + +switch: + - platform: pcm5122 + pcm5122: pcm5122_dac + name: PCM5122 Power Down + power_mode: powerdown diff --git a/tests/components/pixoo/common.yaml b/tests/components/pixoo/common.yaml new file mode 100644 index 0000000000..e854ce8863 --- /dev/null +++ b/tests/components/pixoo/common.yaml @@ -0,0 +1,26 @@ +display: + - platform: pixoo + id: pixoo_display + model: 64x64 + cs_pin: GPIO5 + data_rate: 10MHz + update_interval: 1s + lambda: |- + it.fill(Color(0, 0, 0)); + it.filled_rectangle(0, 0, 16, 16, Color(255, 0, 0)); + it.line(0, 0, 63, 63, Color(0, 255, 0)); + + - platform: pixoo + id: pixoo_display_pages + model: 64x64 + cs_pin: GPIO21 + rotation: 90 + pages: + - id: pixoo_page + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height(), Color(0, 0, 255)); + +light: + - platform: pixoo + pixoo_id: pixoo_display + name: Pixoo Brightness diff --git a/tests/components/pixoo/test.esp32-idf.yaml b/tests/components/pixoo/test.esp32-idf.yaml new file mode 100644 index 0000000000..a8e18ca503 --- /dev/null +++ b/tests/components/pixoo/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/power_supply/test_setup_priority.cpp b/tests/components/power_supply/test_setup_priority.cpp new file mode 100644 index 0000000000..401fc72654 --- /dev/null +++ b/tests/components/power_supply/test_setup_priority.cpp @@ -0,0 +1,47 @@ +#include + +#include "esphome/components/power_supply/power_supply.h" +#include "esphome/core/gpio.h" +#include "esphome/core/component.h" + +namespace esphome::power_supply::testing { + +// Minimal dummy internal GPIO pin implementation for testing +class DummyInternalPin : public InternalGPIOPin { + public: + DummyInternalPin() = default; + void setup() override {} + void pin_mode(esphome::gpio::Flags) override {} + esphome::gpio::Flags get_flags() const override { return esphome::gpio::FLAG_NONE; } + bool digital_read() override { return false; } + void digital_write(bool) override {} + void detach_interrupt() const override {} + ISRInternalGPIOPin to_isr() const override { return ISRInternalGPIOPin(); } + uint8_t get_pin() const override { return 0; } + bool is_inverted() const override { return false; } + + protected: + // Implement protected attach_interrupt required by InternalGPIOPin + void attach_interrupt(void (*func)(void *), void *arg, esphome::gpio::InterruptType type) const override {} +}; + +TEST(PowerSupply, HasHigherPriorityThanBusWhenInternalAndEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(true); + + // POWER priority should be greater than BUS priority + EXPECT_GT(ps.get_setup_priority(), setup_priority::BUS); +} + +TEST(PowerSupply, FallsBackToIOWhenNotEnableOnBoot) { + power_supply::PowerSupply ps; + DummyInternalPin pin; + ps.set_pin(&pin); + ps.set_enable_on_boot(false); + + EXPECT_EQ(ps.get_setup_priority(), setup_priority::IO); +} + +} // namespace esphome::power_supply::testing diff --git a/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml new file mode 100644 index 0000000000..1808e09f5d --- /dev/null +++ b/tests/components/preferences/validate-rtc-storage.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the opt-in that compiles the RTC-backed preference storage into +# the ESP32 backend without any other option selecting it. +preferences: + rtc_storage: true diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml new file mode 100644 index 0000000000..24168881fc --- /dev/null +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -0,0 +1,25 @@ +# Exercises the provisioning window: api registers as a provisioning source +# (encryption enabled, no key), the on_timeout automation, and the wifi + +# esp32_improv cross-component guards. improv_serial is intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: + +binary_sensor: + - platform: gpio + pin: 0 + id: io0_button + +esp32_improv: + authorizer: io0_button diff --git a/tests/components/provisioning/test.esp8266-ard.yaml b/tests/components/provisioning/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4188c00bef --- /dev/null +++ b/tests/components/provisioning/test.esp8266-ard.yaml @@ -0,0 +1,16 @@ +# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source +# and the wifi reboot guard. improv_serial is present and intentionally NOT gated. +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +api: + encryption: + +wifi: + ssid: MySSID + password: password1 + +improv_serial: diff --git a/tests/components/provisioning/validate.esp32-idf.yaml b/tests/components/provisioning/validate.esp32-idf.yaml new file mode 100644 index 0000000000..1fd3d67882 --- /dev/null +++ b/tests/components/provisioning/validate.esp32-idf.yaml @@ -0,0 +1,15 @@ +# A device provisioned over the network (wifi / Improv) with no api: network +# connectivity alone satisfies provisioning, so `provisioning:` is valid without an +# api encryption source. Config-only -- exercises the network provisioning-source +# validation path (the Improv-only case from the review). +provisioning: + timeout: 1min + on_timeout: + then: + - logger.log: "Provisioning window expired" + +wifi: + ssid: MySSID + password: password1 + +improv_serial: diff --git a/tests/components/qmi8658/common.yaml b/tests/components/qmi8658/common.yaml new file mode 100644 index 0000000000..7d4de0f97e --- /dev/null +++ b/tests/components/qmi8658/common.yaml @@ -0,0 +1,70 @@ +sensor: + - platform: qmi8658 + name: "QMI8658 Temperature" + + - platform: motion + type: acceleration_x + name: "Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + type: acceleration_y + name: "Accel Y" + accuracy_decimals: 4 + - platform: motion + type: acceleration_z + name: "Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + type: gyroscope_x + name: "Gyro X" + - platform: motion + type: gyroscope_y + name: "Gyro Y" + - platform: motion + type: gyroscope_z + name: "Gyro Z" + + - platform: motion + type: angular_rate_x + name: "Angular Rate X" + - platform: motion + type: angular_rate_y + name: "Angular Rate Y" + - platform: motion + type: angular_rate_z + name: "Angular Rate Z" + + - platform: motion + type: pitch + name: "Pitch" + - platform: motion + type: roll + name: "Roll" + +motion: + - platform: qmi8658 + i2c_id: i2c_bus + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + + # Accelerometer output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + accelerometer_odr: 1000HZ + + # Gyroscope full-scale range: 16DPS | 32DPS | 64DPS | 128DPS | + # 256DPS | 512DPS | 1024DPS | 2048DPS + gyroscope_range: 2048DPS + + # Gyroscope output data rate: 31_25HZ | 62_5HZ | 125HZ | 250HZ | + # 500HZ | 1000HZ | 2000HZ | 4000HZ | 8000HZ + gyroscope_odr: 1000HZ + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/qmi8658/test.esp32-idf.yaml b/tests/components/qmi8658/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/qmi8658/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/qmi8658/test.esp8266-ard.yaml b/tests/components/qmi8658/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/qmi8658/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp32-idf.yaml b/tests/components/rf_bridge/test.esp32-idf.yaml index 2d29656c94..76222997a8 100644 --- a/tests/components/rf_bridge/test.esp32-idf.yaml +++ b/tests/components/rf_bridge/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.esp8266-ard.yaml b/tests/components/rf_bridge/test.esp8266-ard.yaml index 5a05efa259..aaedec5aaa 100644 --- a/tests/components/rf_bridge/test.esp8266-ard.yaml +++ b/tests/components/rf_bridge/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/rf_bridge/test.rp2040-ard.yaml b/tests/components/rf_bridge/test.rp2040-ard.yaml index f1df2daf83..ed0cd431e3 100644 --- a/tests/components/rf_bridge/test.rp2040-ard.yaml +++ b/tests/components/rf_bridge/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_19200: !include ../../test_build_components/common/uart_19200/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/rp2040/test.rp2040-ard.yaml b/tests/components/rp2/test.rp2040-ard.yaml similarity index 97% rename from tests/components/rp2040/test.rp2040-ard.yaml rename to tests/components/rp2/test.rp2040-ard.yaml index 09531f914e..eaa494a01a 100644 --- a/tests/components/rp2040/test.rp2040-ard.yaml +++ b/tests/components/rp2/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2040 enable_full_printf: false diff --git a/tests/components/rp2040/test.rp2040-pico2-ard.yaml b/tests/components/rp2/test.rp2350-ard.yaml similarity index 90% rename from tests/components/rp2040/test.rp2040-pico2-ard.yaml rename to tests/components/rp2/test.rp2350-ard.yaml index c9d795840d..84ee39a81e 100644 --- a/tests/components/rp2040/test.rp2040-pico2-ard.yaml +++ b/tests/components/rp2/test.rp2350-ard.yaml @@ -1,4 +1,4 @@ -rp2040: +rp2: variant: rp2350 enable_full_printf: false diff --git a/tests/components/safe_mode/test-rtc.esp32-idf.yaml b/tests/components/safe_mode/test-rtc.esp32-idf.yaml new file mode 100644 index 0000000000..113a2b6ab5 --- /dev/null +++ b/tests/components/safe_mode/test-rtc.esp32-idf.yaml @@ -0,0 +1,4 @@ +# Exercises the ESP32 RTC-backed preferences path (storage: rtc) for safe_mode. +safe_mode: + num_attempts: 3 + storage: rtc diff --git a/tests/components/socket/test.nrf52-adafruit.yaml b/tests/components/socket/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-mcumgr.yaml b/tests/components/socket/test.nrf52-mcumgr.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-mcumgr.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/socket/test.nrf52-xiao-ble.yaml b/tests/components/socket/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..d55dfd1557 --- /dev/null +++ b/tests/components/socket/test.nrf52-xiao-ble.yaml @@ -0,0 +1 @@ +socket: diff --git a/tests/components/spi/test.rp2040-pico2-ard.yaml b/tests/components/spi/test.rp2350-ard.yaml similarity index 100% rename from tests/components/spi/test.rp2040-pico2-ard.yaml rename to tests/components/spi/test.rp2350-ard.yaml diff --git a/tests/components/st7123/common.yaml b/tests/components/st7123/common.yaml new file mode 100644 index 0000000000..b34eb669e0 --- /dev/null +++ b/tests/components/st7123/common.yaml @@ -0,0 +1,18 @@ +display: + - platform: ssd1306_i2c + i2c_id: i2c_bus + id: st7123_ssd1306_i2c_display + model: SSD1306_128X64 + reset_pin: ${display_reset_pin} + pages: + - id: st7123_page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + +touchscreen: + - platform: st7123 + i2c_id: i2c_bus + id: st7123_touchscreen + display: st7123_ssd1306_i2c_display + interrupt_pin: ${interrupt_pin} + reset_pin: ${reset_pin} diff --git a/tests/components/st7123/test.esp32-idf.yaml b/tests/components/st7123/test.esp32-idf.yaml new file mode 100644 index 0000000000..3bce86d9a3 --- /dev/null +++ b/tests/components/st7123/test.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + display_reset_pin: "10" + interrupt_pin: "20" + reset_pin: "21" + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/sx126x/common.yaml b/tests/components/sx126x/common.yaml index a4a24d8da7..05794ad1a8 100644 --- a/tests/components/sx126x/common.yaml +++ b/tests/components/sx126x/common.yaml @@ -21,6 +21,8 @@ sx126x: coding_rate: CR_4_6 tcxo_voltage: 1_8V tcxo_delay: 5ms + whitening_enable: false + whitening_initial: 0x1FF on_packet: then: - lambda: |- diff --git a/tests/components/uart/common.h b/tests/components/uart/common.h index de3ea3029e..5c4ba1130e 100644 --- a/tests/components/uart/common.h +++ b/tests/components/uart/common.h @@ -32,6 +32,9 @@ class MockUARTComponent : public UARTComponent { MOCK_METHOD(size_t, available, (), (override)); MOCK_METHOD(UARTFlushResult, flush, (), (override)); MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + MOCK_METHOD(void, load_settings, (bool dump_config), (override)); +#endif }; } // namespace esphome::uart::testing 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/waveshare_io_ch32v003/common.yaml b/tests/components/waveshare_io_ch32v003/common.yaml new file mode 100644 index 0000000000..c8805583c7 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/common.yaml @@ -0,0 +1,34 @@ +waveshare_io_ch32v003: + - id: wave_io + i2c_id: i2c_bus + address: 0x24 + +binary_sensor: + - platform: gpio + id: wave_io_binary_sensor + pin: + waveshare_io_ch32v003: wave_io + number: 3 + mode: INPUT + inverted: false + +output: + - platform: gpio + id: wave_io_output + pin: + waveshare_io_ch32v003: wave_io + number: 0 + mode: OUTPUT + inverted: false + + - platform: waveshare_io_ch32v003 + id: wave_io_pwm_output + inverted: true + zero_means_zero: true + safe_pwm_levels: + min_value: 0 + max_value: 247 + +sensor: + - platform: waveshare_io_ch32v003 + id: wave_io_adc diff --git a/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/waveshare_io_ch32v003/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/web_server/common_v2.yaml b/tests/components/web_server/common_v2.yaml index f2b15e484d..b9bc0bbf61 100644 --- a/tests/components/web_server/common_v2.yaml +++ b/tests/components/web_server/common_v2.yaml @@ -5,3 +5,6 @@ web_server: port: 8080 version: 2 compression: br + enable_private_network_access: true + allowed_origins: + - https://app.esphome.io diff --git a/tests/components/web_server/common_v3.yaml b/tests/components/web_server/common_v3.yaml index bdacaaddbe..354d7bb6ac 100644 --- a/tests/components/web_server/common_v3.yaml +++ b/tests/components/web_server/common_v3.yaml @@ -4,6 +4,9 @@ packages: web_server: port: 8080 version: 3 + # allowed_origins can be set independently of Private Network Access + allowed_origins: + - https://app.esphome.io sorting_groups: - id: sorting_group_1 name: "Group 1 Diplayed Last" diff --git a/tests/components/web_server/test.esp32-ard.yaml b/tests/components/web_server/test.esp32-ard.yaml index 7e6658e20e..11ad5456ef 100644 --- a/tests/components/web_server/test.esp32-ard.yaml +++ b/tests/components/web_server/test.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml diff --git a/tests/components/web_server/test.esp32-idf.yaml b/tests/components/web_server/test.esp32-idf.yaml index 24b292d0d6..2e091b905a 100644 --- a/tests/components/web_server/test.esp32-idf.yaml +++ b/tests/components/web_server/test.esp32-idf.yaml @@ -1,6 +1,8 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml web_server: auth: username: admin password: password + type: digest diff --git a/tests/components/web_server/test.esp8266-ard.yaml b/tests/components/web_server/test.esp8266-ard.yaml index 7e6658e20e..2e091b905a 100644 --- a/tests/components/web_server/test.esp8266-ard.yaml +++ b/tests/components/web_server/test.esp8266-ard.yaml @@ -1 +1,8 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index 7e6658e20e..e4d50d7776 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -1 +1,8 @@ -<<: !include common_v2.yaml +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/components/web_server/test_v1.esp32-ard.yaml b/tests/components/web_server/test_v1.esp32-ard.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-ard.yaml +++ b/tests/components/web_server/test_v1.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v1.esp32-idf.yaml b/tests/components/web_server/test_v1.esp32-idf.yaml index 389a930284..1d563de834 100644 --- a/tests/components/web_server/test_v1.esp32-idf.yaml +++ b/tests/components/web_server/test_v1.esp32-idf.yaml @@ -1 +1,2 @@ -<<: !include common_v1.yaml +packages: + web_server: !include common_v1.yaml diff --git a/tests/components/web_server/test_v3.esp32-ard.yaml b/tests/components/web_server/test_v3.esp32-ard.yaml index 00d05521e4..956a88bc68 100644 --- a/tests/components/web_server/test_v3.esp32-ard.yaml +++ b/tests/components/web_server/test_v3.esp32-ard.yaml @@ -1 +1,2 @@ -<<: !include common_v3.yaml +packages: + web_server: !include common_v3.yaml diff --git a/tests/components/web_server/validate.esp32-idf.yaml b/tests/components/web_server/validate.esp32-idf.yaml new file mode 100644 index 0000000000..e4d50d7776 --- /dev/null +++ b/tests/components/web_server/validate.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: password + type: basic 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/components/wifi/validate-fast-connect-storage.esp32-idf.yaml b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml new file mode 100644 index 0000000000..93d223b908 --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp32-idf.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect with RTC-backed preference storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + enabled: true + storage: rtc diff --git a/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml new file mode 100644 index 0000000000..070e22fd5b --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-storage.esp8266-ard.yaml @@ -0,0 +1,7 @@ +# Exercises the dict form of fast_connect overriding the ESP8266 default (rtc) +# back to flash storage. +wifi: + ssid: MySSID + password: password1 + fast_connect: + storage: flash diff --git a/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml new file mode 100644 index 0000000000..f9fab8261a --- /dev/null +++ b/tests/components/wifi/validate-fast-connect-substitution.esp8266-ard.yaml @@ -0,0 +1,9 @@ +# fast_connect passed through a substitution arrives as a string ("false"), +# which must be accepted like the historic plain boolean form. +substitutions: + fast_connect_value: "false" + +wifi: + ssid: MySSID + password: password1 + fast_connect: ${fast_connect_value} diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 82a523fc7c..6cac9c9e2a 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -4,14 +4,25 @@ packages: binary_sensor: - platform: template name: "Garage Door Open 10" - report: "enable" + report: "default" + use_device_type: false - platform: template name: "Garage Door Open 12" report: "force" + endpoint: 1 + +sensor: + - platform: template + name: "Temperature Sensor" + lambda: return 10.0; + device_class: temperature + unit_of_measurement: "°C" + endpoint: 1 + use_device_type: true zigbee: model: zigbee_test - router: true + router: false power_source: MAINS_SINGLE_PHASE on_join: then: diff --git a/tests/components/zigbee/test-router.esp32-c6-idf.yaml b/tests/components/zigbee/test-router.esp32-c6-idf.yaml new file mode 100644 index 0000000000..228fe331e5 --- /dev/null +++ b/tests/components/zigbee/test-router.esp32-c6-idf.yaml @@ -0,0 +1,7 @@ +zigbee: + model: zigbee_test + router: true + power_source: MAINS_SINGLE_PHASE + on_join: + then: + - logger.log: "Joined network" diff --git a/tests/dashboard/common.py b/tests/dashboard/common.py deleted file mode 100644 index f84c03aad8..0000000000 --- a/tests/dashboard/common.py +++ /dev/null @@ -1,6 +0,0 @@ -import pathlib - - -def get_fixture_path(filename: str) -> pathlib.Path: - """Get path of fixture.""" - return pathlib.Path(__file__).parent.joinpath("fixtures", filename) diff --git a/tests/dashboard/conftest.py b/tests/dashboard/conftest.py deleted file mode 100644 index f95adef749..0000000000 --- a/tests/dashboard/conftest.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Common fixtures for dashboard tests.""" - -from __future__ import annotations - -from pathlib import Path -from unittest.mock import MagicMock, Mock - -import pytest -import pytest_asyncio - -from esphome.dashboard.core import ESPHomeDashboard -from esphome.dashboard.entries import DashboardEntries - - -@pytest.fixture -def mock_settings(tmp_path: Path) -> MagicMock: - """Create mock dashboard settings.""" - settings = MagicMock() - settings.config_dir = str(tmp_path) - settings.absolute_config_dir = tmp_path - return settings - - -@pytest.fixture -def mock_dashboard(mock_settings: MagicMock) -> Mock: - """Create a mock dashboard.""" - dashboard = Mock(spec=ESPHomeDashboard) - dashboard.settings = mock_settings - dashboard.entries = Mock() - dashboard.entries.async_all.return_value = [] - dashboard.stop_event = Mock() - dashboard.stop_event.is_set.return_value = True - dashboard.ping_request = Mock() - dashboard.ignored_devices = set() - dashboard.bus = Mock() - dashboard.bus.async_fire = Mock() - return dashboard - - -@pytest_asyncio.fixture -async def dashboard_entries(mock_dashboard: Mock) -> DashboardEntries: - """Create a DashboardEntries instance for testing.""" - return DashboardEntries(mock_dashboard) diff --git a/tests/dashboard/fixtures/conf/pico.yaml b/tests/dashboard/fixtures/conf/pico.yaml deleted file mode 100644 index cf5b5b75bf..0000000000 --- a/tests/dashboard/fixtures/conf/pico.yaml +++ /dev/null @@ -1,47 +0,0 @@ -substitutions: - name: picoproxy - friendly_name: Pico Proxy - -esphome: - name: ${name} - friendly_name: ${friendly_name} - project: - name: esphome.bluetooth-proxy - version: "1.0" - -esp32: - board: esp32dev - framework: - type: esp-idf - -wifi: - ap: - -api: -logger: -ota: -improv_serial: - -dashboard_import: - package_import_url: github://esphome/firmware/bluetooth-proxy/esp32-generic.yaml@main - -button: - - platform: factory_reset - id: resetf - - platform: safe_mode - name: Safe Mode Boot - entity_category: diagnostic - -sensor: - - platform: template - id: pm11 - name: "pm 1.0µm" - lambda: return 1.0; - - platform: template - id: pm251 - name: "pm 2.5µm" - lambda: return 2.5; - - platform: template - id: pm101 - name: "pm 10µm" - lambda: return 10; diff --git a/tests/dashboard/status/test_dns.py b/tests/dashboard/status/test_dns.py deleted file mode 100644 index f7c4992079..0000000000 --- a/tests/dashboard/status/test_dns.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Unit tests for esphome.dashboard.dns module.""" - -from __future__ import annotations - -import time -from unittest.mock import AsyncMock, patch - -from icmplib import NameLookupError -import pytest - -from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper - - -@pytest.fixture -def dns_cache_fixture() -> DNSCache: - """Create a DNSCache instance.""" - return DNSCache() - - -def test_get_cached_addresses_not_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when hostname is not in cache.""" - now = time.monotonic() - result = dns_cache_fixture.get_cached_addresses("unknown.example.com", now) - assert result is None - - -def test_get_cached_addresses_expired(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache entry is expired.""" - now = time.monotonic() - # Add entry that's already expired - dns_cache_fixture._cache["example.com"] = (now - 1, ["192.168.1.10"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None - # Expired entry should still be in cache (not removed by get_cached_addresses) - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_valid(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with valid cache entry.""" - now = time.monotonic() - # Add entry that expires in 60 seconds - dns_cache_fixture._cache["example.com"] = ( - now + 60, - ["192.168.1.10", "192.168.1.11"], - ) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["192.168.1.10", "192.168.1.11"] - # Entry should still be in cache - assert "example.com" in dns_cache_fixture._cache - - -def test_get_cached_addresses_hostname_normalization( - dns_cache_fixture: DNSCache, -) -> None: - """Test get_cached_addresses normalizes hostname.""" - now = time.monotonic() - # Add entry with lowercase hostname - dns_cache_fixture._cache["example.com"] = (now + 60, ["192.168.1.10"]) - - # Test with various forms - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("example.com.", now) == [ - "192.168.1.10" - ] - assert dns_cache_fixture.get_cached_addresses("EXAMPLE.COM.", now) == [ - "192.168.1.10" - ] - - -def test_get_cached_addresses_ipv6(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with IPv6 addresses.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, ["2001:db8::1", "fe80::1"]) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == ["2001:db8::1", "fe80::1"] - - -def test_get_cached_addresses_empty_list(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses with empty address list.""" - now = time.monotonic() - dns_cache_fixture._cache["example.com"] = (now + 60, []) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result == [] - - -def test_get_cached_addresses_exception_in_cache(dns_cache_fixture: DNSCache) -> None: - """Test get_cached_addresses when cache contains an exception.""" - now = time.monotonic() - # Store an exception (from failed resolution) - dns_cache_fixture._cache["example.com"] = (now + 60, OSError("Resolution failed")) - - result = dns_cache_fixture.get_cached_addresses("example.com", now) - assert result is None # Should return None for exceptions - - -def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None: - """Test that get_cached_addresses never calls async_resolve.""" - now = time.monotonic() - - with patch.object(dns_cache_fixture, "async_resolve") as mock_resolve: - # Test non-cached - result = dns_cache_fixture.get_cached_addresses("uncached.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test expired - dns_cache_fixture._cache["expired.com"] = (now - 1, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("expired.com", now) - assert result is None - mock_resolve.assert_not_called() - - # Test valid - dns_cache_fixture._cache["valid.com"] = (now + 60, ["192.168.1.10"]) - result = dns_cache_fixture.get_cached_addresses("valid.com", now) - assert result == ["192.168.1.10"] - mock_resolve.assert_not_called() - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_ip_address() -> None: - """Test _async_resolve_wrapper returns IP address directly.""" - result = await _async_resolve_wrapper("192.168.1.10") - assert result == ["192.168.1.10"] - - result = await _async_resolve_wrapper("2001:db8::1") - assert result == ["2001:db8::1"] - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_success() -> None: - """Test _async_resolve_wrapper falls back to bare hostname for .local.""" - mock_resolve = AsyncMock() - # First call (device.local) fails, second call (device) succeeds - mock_resolve.side_effect = [ - NameLookupError("device.local"), - ["192.168.1.50"], - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - assert mock_resolve.call_count == 2 - mock_resolve.assert_any_call("device.local") - mock_resolve.assert_any_call("device") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_fallback_both_fail() -> None: - """Test _async_resolve_wrapper returns exception when both fail.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.local") - mock_resolve.side_effect = [ - original_exception, - NameLookupError("device"), - ] - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - # Should return the original exception, not the fallback exception - assert result is original_exception - assert mock_resolve.call_count == 2 - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_non_local_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback for non-.local hostnames.""" - mock_resolve = AsyncMock() - original_exception = NameLookupError("device.example.com") - mock_resolve.side_effect = original_exception - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.example.com") - - assert result is original_exception - # Should only try the original hostname, no fallback - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.example.com") - - -@pytest.mark.asyncio -async def test_async_resolve_wrapper_local_success_no_fallback() -> None: - """Test _async_resolve_wrapper doesn't fallback when .local succeeds.""" - mock_resolve = AsyncMock(return_value=["192.168.1.50"]) - - with patch("esphome.dashboard.dns.async_resolve", mock_resolve): - result = await _async_resolve_wrapper("device.local") - - assert result == ["192.168.1.50"] - # Should only try once since it succeeded - assert mock_resolve.call_count == 1 - mock_resolve.assert_called_once_with("device.local") diff --git a/tests/dashboard/status/test_mdns.py b/tests/dashboard/status/test_mdns.py deleted file mode 100644 index 56c6d254cf..0000000000 --- a/tests/dashboard/status/test_mdns.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Unit tests for esphome.dashboard.status.mdns module.""" - -from __future__ import annotations - -from unittest.mock import Mock, patch - -import pytest -import pytest_asyncio -from zeroconf import AddressResolver, IPVersion - -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.status.mdns import MDNSStatus -from esphome.zeroconf import DiscoveredImport - - -@pytest_asyncio.fixture -async def mdns_status(mock_dashboard: Mock) -> MDNSStatus: - """Create an MDNSStatus instance in async context.""" - # We're in an async context so get_running_loop will work - return MDNSStatus(mock_dashboard) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_no_zeroconf(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when no zeroconf instance is available.""" - mdns_status.aiozc = None - result = mdns_status.get_cached_addresses("device.local") - assert result is None - - -@pytest.mark.asyncio -async def test_get_cached_addresses_not_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is not in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = False - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result is None - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_found_in_cache(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses when address is found in cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10", "fe80::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["192.168.1.10", "fe80::1"] - mock_info.load_from_cache.assert_called_once_with(mdns_status.aiozc.zeroconf) - mock_info.parsed_scoped_addresses.assert_called_once_with(IPVersion.All) - - -@pytest.mark.asyncio -async def test_get_cached_addresses_with_trailing_dot(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with hostname having trailing dot.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local.") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_uppercase_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with uppercase hostname.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("DEVICE.LOCAL") - assert result == ["192.168.1.10"] - # Should normalize to device.local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_simple_hostname(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses with simple hostname (no domain).""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["192.168.1.10"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device") - assert result == ["192.168.1.10"] - # Should append .local. for zeroconf - mock_resolver.assert_called_once_with("device.local.") - - -@pytest.mark.asyncio -async def test_get_cached_addresses_ipv6_only(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning only IPv6 addresses.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = ["fe80::1", "2001:db8::1"] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == ["fe80::1", "2001:db8::1"] - - -@pytest.mark.asyncio -async def test_get_cached_addresses_empty_list(mdns_status: MDNSStatus) -> None: - """Test get_cached_addresses returning empty list from cache.""" - mdns_status.aiozc = Mock() - mdns_status.aiozc.zeroconf = Mock() - - with patch("esphome.dashboard.status.mdns.AddressResolver") as mock_resolver: - mock_info = Mock(spec=AddressResolver) - mock_info.load_from_cache.return_value = True - mock_info.parsed_scoped_addresses.return_value = [] - mock_resolver.return_value = mock_info - - result = mdns_status.get_cached_addresses("device.local") - assert result == [] - - -@pytest.mark.asyncio -async def test_async_setup_success(mock_dashboard: Mock) -> None: - """Test successful async_setup.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.return_value = Mock() - result = mdns_status.async_setup() - assert result is True - assert mdns_status.aiozc is not None - - -@pytest.mark.asyncio -async def test_async_setup_failure(mock_dashboard: Mock) -> None: - """Test async_setup with OSError.""" - mdns_status = MDNSStatus(mock_dashboard) - with patch("esphome.dashboard.status.mdns.AsyncEsphomeZeroconf") as mock_zc: - mock_zc.side_effect = OSError("Network error") - result = mdns_status.async_setup() - assert result is False - assert mdns_status.aiozc is None - - -@pytest.mark.asyncio -async def test_on_import_update_device_added(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is added.""" - # Create a DiscoveredImport object - discovered = DiscoveredImport( - device_name="test_device", - friendly_name="Test Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Call _on_import_update with a device - mdns_status._on_import_update("test_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - assert "device" in call_args[0][1] - device_data = call_args[0][1]["device"] - assert device_data["name"] == "test_device" - assert device_data["friendly_name"] == "Test Device" - assert device_data["project_name"] == "test_project" - assert device_data["ignored"] is False - - -@pytest.mark.asyncio -async def test_on_import_update_device_ignored(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is ignored.""" - # Add device to ignored list - mdns_status.dashboard.ignored_devices.add("ignored_device") - - # Create a DiscoveredImport object for ignored device - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Call _on_import_update with an ignored device - mdns_status._on_import_update("ignored_device", discovered) - - # Should fire IMPORTABLE_DEVICE_ADDED event with ignored=True - mock_dashboard = mdns_status.dashboard - mock_dashboard.bus.async_fire.assert_called_once() - call_args = mock_dashboard.bus.async_fire.call_args - assert call_args[0][0] == DashboardEvent.IMPORTABLE_DEVICE_ADDED - device_data = call_args[0][1]["device"] - assert device_data["name"] == "ignored_device" - assert device_data["ignored"] is True - - -@pytest.mark.asyncio -async def test_on_import_update_device_removed(mdns_status: MDNSStatus) -> None: - """Test _on_import_update when a device is removed.""" - # Call _on_import_update with None (device removed) - mdns_status._on_import_update("removed_device", None) - - # Should fire IMPORTABLE_DEVICE_REMOVED event - mdns_status.dashboard.bus.async_fire.assert_called_once_with( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, {"name": "removed_device"} - ) diff --git a/tests/dashboard/test_entries.py b/tests/dashboard/test_entries.py deleted file mode 100644 index 9a3a776b28..0000000000 --- a/tests/dashboard/test_entries.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Tests for dashboard entries Path-related functionality.""" - -from __future__ import annotations - -import os -from pathlib import Path -import tempfile -from unittest.mock import Mock - -import pytest - -from esphome.core import CORE -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.entries import DashboardEntries, DashboardEntry - - -def create_cache_key() -> tuple[int, int, float, int]: - """Helper to create a valid DashboardCacheKeyType.""" - return (0, 0, 0.0, 0) - - -@pytest.fixture(autouse=True) -def setup_core(): - """Set up CORE for testing.""" - with tempfile.TemporaryDirectory() as tmpdir: - CORE.config_path = Path(tmpdir) / "test.yaml" - yield - CORE.reset() - - -def test_dashboard_entry_path_initialization() -> None: - """Test DashboardEntry initializes with path correctly.""" - test_path = Path("/test/config/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.cache_key == cache_key - - -def test_dashboard_entry_path_with_absolute_path() -> None: - """Test DashboardEntry handles absolute paths.""" - # Use a truly absolute path for the platform - test_path = Path.cwd() / "absolute" / "path" / "to" / "config.yaml" - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert entry.path.is_absolute() - - -def test_dashboard_entry_path_with_relative_path() -> None: - """Test DashboardEntry handles relative paths.""" - test_path = Path("configs/device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - assert not entry.path.is_absolute() - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_by_path( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test getting entry by path.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Verify the entry was loaded - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - assert entry.path == test_file - - # Also verify get() works with Path - result = dashboard_entries.get(test_file) - assert result == entry - - -@pytest.mark.asyncio -async def test_dashboard_entries_get_nonexistent_path( - dashboard_entries: DashboardEntries, -) -> None: - """Test getting non-existent entry returns None.""" - result = dashboard_entries.get("/nonexistent/path.yaml") - assert result is None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_normalization( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that paths are handled consistently.""" - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_spaces( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with spaces.""" - # Create a test file with spaces in name - test_file = tmp_path / "my device.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - assert result.path == test_file - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_with_special_chars( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test handling paths with special characters.""" - # Create a test file with special characters - test_file = tmp_path / "device-01_test.yaml" - test_file.write_text("test config") - - # Update entries to load the file - await dashboard_entries.async_update_entries() - - # Get the entry by path - result = dashboard_entries.get(test_file) - assert result is not None - - -def test_dashboard_entries_windows_path() -> None: - """Test handling Windows-style paths.""" - test_path = Path(r"C:\Users\test\esphome\device.yaml") - cache_key = create_cache_key() - - entry = DashboardEntry(test_path, cache_key) - - assert entry.path == test_path - - -@pytest.mark.asyncio -async def test_dashboard_entries_path_to_cache_key_mapping( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test internal entries storage with paths and cache keys.""" - # Create test files - file1 = tmp_path / "device1.yaml" - file2 = tmp_path / "device2.yaml" - file1.write_text("test config 1") - file2.write_text("test config 2") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - # Get entries and verify they have different cache keys - entry1 = dashboard_entries.get(file1) - entry2 = dashboard_entries.get(file2) - - assert entry1 is not None - assert entry2 is not None - assert entry1.cache_key != entry2.cache_key - - -def test_dashboard_entry_path_property() -> None: - """Test that path property returns expected value.""" - test_path = Path("/test/config/device.yaml") - entry = DashboardEntry(test_path, create_cache_key()) - - assert entry.path == test_path - assert isinstance(entry.path, Path) - - -@pytest.mark.asyncio -async def test_dashboard_entries_all_returns_entries_with_paths( - dashboard_entries: DashboardEntries, tmp_path: Path -) -> None: - """Test that all() returns entries with their paths intact.""" - # Create test files - files = [ - tmp_path / "device1.yaml", - tmp_path / "device2.yaml", - tmp_path / "device3.yaml", - ] - - for file in files: - file.write_text("test config") - - # Update entries to load the files - await dashboard_entries.async_update_entries() - - all_entries = dashboard_entries.async_all() - - assert len(all_entries) == len(files) - retrieved_paths = [entry.path for entry in all_entries] - assert set(retrieved_paths) == set(files) - - -@pytest.mark.asyncio -async def test_async_update_entries_removed_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that removed files trigger ENTRY_REMOVED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - - # Delete the file - test_file.unlink() - - # Second update to detect removal - await dashboard_entries.async_update_entries() - - # Verify entry was removed - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 0 - - # Verify ENTRY_REMOVED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_REMOVED, {"entry": entry} - ) - - -@pytest.mark.asyncio -async def test_async_update_entries_updated_path( - dashboard_entries: DashboardEntries, mock_dashboard: Mock, tmp_path: Path -) -> None: - """Test that modified files trigger ENTRY_UPDATED event.""" - - # Create a test file - test_file = tmp_path / "device.yaml" - test_file.write_text("test config") - - # First update to add the entry - await dashboard_entries.async_update_entries() - - # Verify entry was added - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - entry = all_entries[0] - original_cache_key = entry.cache_key - - # Modify the file to change its mtime - test_file.write_text("updated config") - # Explicitly change the mtime to ensure it's different - stat = test_file.stat() - os.utime(test_file, (stat.st_atime, stat.st_mtime + 1)) - - # Second update to detect modification - await dashboard_entries.async_update_entries() - - # Verify entry is still there with updated cache key - all_entries = dashboard_entries.async_all() - assert len(all_entries) == 1 - updated_entry = all_entries[0] - assert updated_entry == entry # Same entry object - assert updated_entry.cache_key != original_cache_key # But cache key updated - - # Verify ENTRY_UPDATED event was fired - mock_dashboard.bus.async_fire.assert_any_call( - DashboardEvent.ENTRY_UPDATED, {"entry": entry} - ) diff --git a/tests/dashboard/test_settings.py b/tests/dashboard/test_settings.py deleted file mode 100644 index 55776ac7c4..0000000000 --- a/tests/dashboard/test_settings.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Tests for DashboardSettings (path resolution and authentication).""" - -from __future__ import annotations - -from argparse import Namespace -from pathlib import Path -import tempfile - -import pytest - -from esphome.core import CORE -from esphome.dashboard.settings import DashboardSettings -from esphome.dashboard.util.password import password_hash - - -@pytest.fixture -def dashboard_settings(tmp_path: Path) -> DashboardSettings: - """Create DashboardSettings instance with temp directory.""" - settings = DashboardSettings() - # Resolve symlinks to ensure paths match - resolved_dir = tmp_path.resolve() - settings.config_dir = resolved_dir - settings.absolute_config_dir = resolved_dir - return settings - - -def test_rel_path_simple(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with simple relative path.""" - result = dashboard_settings.rel_path("config.yaml") - - expected = dashboard_settings.config_dir / "config.yaml" - assert result == expected - - -def test_rel_path_multiple_components(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with multiple path components.""" - result = dashboard_settings.rel_path("subfolder", "device", "config.yaml") - - expected = dashboard_settings.config_dir / "subfolder" / "device" / "config.yaml" - assert result == expected - - -def test_rel_path_with_dots(dashboard_settings: DashboardSettings) -> None: - """Test rel_path prevents directory traversal.""" - # This should raise ValueError as it tries to go outside config_dir - with pytest.raises(ValueError): - dashboard_settings.rel_path("..", "outside.yaml") - - -def test_rel_path_absolute_path_within_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path that's within config dir.""" - internal_path = dashboard_settings.absolute_config_dir / "internal.yaml" - - internal_path.touch() - result = dashboard_settings.rel_path("internal.yaml") - expected = dashboard_settings.config_dir / "internal.yaml" - assert result == expected - - -def test_rel_path_absolute_path_outside_config( - dashboard_settings: DashboardSettings, -) -> None: - """Test rel_path with absolute path outside config dir raises error.""" - outside_path = "/tmp/outside/config.yaml" - - with pytest.raises(ValueError): - dashboard_settings.rel_path(outside_path) - - -def test_rel_path_empty_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with no arguments returns config_dir.""" - result = dashboard_settings.rel_path() - assert result == dashboard_settings.config_dir - - -def test_rel_path_with_pathlib_path(dashboard_settings: DashboardSettings) -> None: - """Test rel_path works with Path objects as arguments.""" - path_obj = Path("subfolder") / "config.yaml" - result = dashboard_settings.rel_path(path_obj) - - expected = dashboard_settings.config_dir / "subfolder" / "config.yaml" - assert result == expected - - -def test_rel_path_normalizes_slashes(dashboard_settings: DashboardSettings) -> None: - """Test rel_path normalizes path separators.""" - # os.path.join normalizes slashes on Windows but preserves them on Unix - # Test that providing components separately gives same result - result1 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - result2 = dashboard_settings.rel_path("folder", "subfolder", "file.yaml") - assert result1 == result2 - - # Also test that the result is as expected - expected = dashboard_settings.config_dir / "folder" / "subfolder" / "file.yaml" - assert result1 == expected - - -def test_rel_path_handles_spaces(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with spaces.""" - result = dashboard_settings.rel_path("my folder", "my config.yaml") - - expected = dashboard_settings.config_dir / "my folder" / "my config.yaml" - assert result == expected - - -def test_rel_path_handles_special_chars(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles paths with special characters.""" - result = dashboard_settings.rel_path("device-01_test", "config.yaml") - - expected = dashboard_settings.config_dir / "device-01_test" / "config.yaml" - assert result == expected - - -def test_config_dir_as_path_property(dashboard_settings: DashboardSettings) -> None: - """Test that config_dir can be accessed and used with Path operations.""" - config_path = dashboard_settings.config_dir - - assert config_path.exists() - assert config_path.is_dir() - assert config_path.is_absolute() - - -def test_absolute_config_dir_property(dashboard_settings: DashboardSettings) -> None: - """Test absolute_config_dir is a Path object.""" - assert isinstance(dashboard_settings.absolute_config_dir, Path) - assert dashboard_settings.absolute_config_dir.exists() - assert dashboard_settings.absolute_config_dir.is_dir() - assert dashboard_settings.absolute_config_dir.is_absolute() - - -def test_rel_path_symlink_inside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points inside config dir.""" - target = dashboard_settings.absolute_config_dir / "target.yaml" - target.touch() - symlink = dashboard_settings.absolute_config_dir / "link.yaml" - symlink.symlink_to(target) - result = dashboard_settings.rel_path("link.yaml") - expected = dashboard_settings.config_dir / "link.yaml" - assert result == expected - - -def test_rel_path_symlink_outside_config(dashboard_settings: DashboardSettings) -> None: - """Test rel_path with symlink that points outside config dir.""" - with tempfile.NamedTemporaryFile(suffix=".yaml") as tmp: - symlink = dashboard_settings.absolute_config_dir / "external_link.yaml" - symlink.symlink_to(tmp.name) - with pytest.raises(ValueError): - dashboard_settings.rel_path("external_link.yaml") - - -def test_rel_path_with_none_arg(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles None arguments gracefully.""" - result = dashboard_settings.rel_path("None") - expected = dashboard_settings.config_dir / "None" - assert result == expected - - -def test_rel_path_with_numeric_args(dashboard_settings: DashboardSettings) -> None: - """Test rel_path handles numeric arguments.""" - result = dashboard_settings.rel_path("123", "456.789") - expected = dashboard_settings.config_dir / "123" / "456.789" - assert result == expected - - -def test_config_path_parent_resolves_to_config_dir(tmp_path: Path) -> None: - """Test that CORE.config_path.parent resolves to config_dir after parse_args. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - The issue was that after switching from os.path to Path: - - Before: os.path.dirname("/config/.") → "/config" - - After: Path("/config/.").parent → Path("/") (normalized first!) - - The fix uses a sentinel file so .parent returns the correct directory: - - Fixed: Path("/config/___DASHBOARD_SENTINEL___.yaml").parent → Path("/config") - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-device.yaml" - device_config.write_text( - "esphome:\n name: test-device\n\npackages:\n common: !include common.yaml\n" - ) - - # Set up dashboard settings with our test config directory - settings = DashboardSettings() - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - settings.parse_args(args) - - # Verify that CORE.config_path.parent correctly points to the config directory - # This is critical for secret resolution in yaml_util.py which does: - # main_config_dir = CORE.config_path.parent - # main_secret_yml = main_config_dir / "secrets.yaml" - assert CORE.config_path.parent == config_dir.resolve() - assert (CORE.config_path.parent / "secrets.yaml").exists() - assert (CORE.config_path.parent / "common.yaml").exists() - - # Verify that CORE.config_path itself uses the sentinel file - assert CORE.config_path.name == "___DASHBOARD_SENTINEL___.yaml" - assert not CORE.config_path.exists() # Sentinel file doesn't actually exist - - -@pytest.fixture -def auth_settings(dashboard_settings: DashboardSettings) -> DashboardSettings: - """Create DashboardSettings with auth configured, based on dashboard_settings.""" - dashboard_settings.username = "admin" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("correctpassword") - return dashboard_settings - - -def test_check_password_correct_credentials(auth_settings: DashboardSettings) -> None: - """Test check_password returns True for correct username and password.""" - assert auth_settings.check_password("admin", "correctpassword") is True - - -def test_check_password_wrong_password(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong password.""" - assert auth_settings.check_password("admin", "wrongpassword") is False - - -def test_check_password_wrong_username(auth_settings: DashboardSettings) -> None: - """Test check_password returns False for wrong username.""" - assert auth_settings.check_password("notadmin", "correctpassword") is False - - -def test_check_password_both_wrong(auth_settings: DashboardSettings) -> None: - """Test check_password returns False when both are wrong.""" - assert auth_settings.check_password("notadmin", "wrongpassword") is False - - -def test_check_password_no_auth(dashboard_settings: DashboardSettings) -> None: - """Test check_password returns True when auth is not configured.""" - assert dashboard_settings.check_password("anyone", "anything") is True - - -def test_check_password_non_ascii_username( - dashboard_settings: DashboardSettings, -) -> None: - """Test check_password handles non-ASCII usernames without TypeError.""" - dashboard_settings.username = "\u00e9l\u00e8ve" - dashboard_settings.using_password = True - dashboard_settings.password_hash = password_hash("pass") - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "pass") is True - assert dashboard_settings.check_password("\u00e9l\u00e8ve", "wrong") is False - assert dashboard_settings.check_password("other", "pass") is False - - -def test_check_password_ha_addon_no_password( - dashboard_settings: DashboardSettings, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test check_password doesn't crash in HA add-on mode without a password. - - In HA add-on mode, using_ha_addon_auth can be True while using_password - is False, leaving password_hash as b"". This must not raise TypeError - in hmac.compare_digest. - """ - monkeypatch.delenv("DISABLE_HA_AUTHENTICATION", raising=False) - dashboard_settings.on_ha_addon = True - dashboard_settings.using_password = False - # password_hash stays as default b"" - assert dashboard_settings.check_password("anyone", "anything") is False diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py deleted file mode 100644 index 0ee841e68c..0000000000 --- a/tests/dashboard/test_web_server.py +++ /dev/null @@ -1,1889 +0,0 @@ -from __future__ import annotations - -from argparse import Namespace -import asyncio -import base64 -from collections.abc import Generator -from contextlib import asynccontextmanager -import gzip -import json -import os -from pathlib import Path -import sys -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse -from tornado.httpserver import HTTPServer -from tornado.ioloop import IOLoop -from tornado.testing import bind_unused_port -from tornado.websocket import WebSocketClientConnection, websocket_connect - -from esphome import yaml_util -from esphome.core import CORE -from esphome.dashboard import web_server -from esphome.dashboard.const import DashboardEvent -from esphome.dashboard.core import DASHBOARD -from esphome.dashboard.entries import ( - DashboardEntry, - EntryStateSource, - bool_to_entry_state, -) -from esphome.dashboard.models import build_importable_device_dict -from esphome.dashboard.web_server import DashboardSubscriber, EsphomeCommandWebSocket -from esphome.zeroconf import DiscoveredImport - -from .common import get_fixture_path - - -def get_build_path(base_path: Path, device_name: str) -> Path: - """Get the build directory path for a device. - - This is a test helper that constructs the standard ESPHome build directory - structure. Note: This helper does NOT perform path traversal sanitization - because it's only used in tests where we control the inputs. The actual - web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() - via file_name.replace("..", "").lstrip("/"). - - Args: - base_path: The base temporary path (typically tmp_path from pytest) - device_name: The name of the device (should not contain path separators - in production use, but tests may use it for specific scenarios) - - Returns: - Path to the build directory (.esphome/build/device_name) - """ - return base_path / ".esphome" / "build" / device_name - - -class DashboardTestHelper: - def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: - self.io_loop = io_loop - self.client = client - self.port = port - - async def fetch(self, path: str, **kwargs) -> HTTPResponse: - """Get a response for the given path.""" - if path.lower().startswith(("http://", "https://")): - url = path - else: - url = f"http://127.0.0.1:{self.port}{path}" - future = self.client.fetch(url, raise_error=True, **kwargs) - return await future - - -@pytest.fixture -def mock_async_run_system_command() -> Generator[MagicMock]: - """Fixture to mock async_run_system_command.""" - with patch("esphome.dashboard.web_server.async_run_system_command") as mock: - yield mock - - -@pytest.fixture -def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock trash_storage_path.""" - trash_dir = tmp_path / "trash" - with patch( - "esphome.dashboard.web_server.trash_storage_path", return_value=trash_dir - ) as mock: - yield mock - - -@pytest.fixture -def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock archive_storage_path.""" - archive_dir = tmp_path / "archive" - with patch( - "esphome.dashboard.web_server.archive_storage_path", - return_value=archive_dir, - ) as mock: - yield mock - - -@pytest.fixture -def mock_dashboard_settings() -> Generator[MagicMock]: - """Fixture to mock dashboard settings.""" - with patch("esphome.dashboard.web_server.settings") as mock_settings: - # Set default auth settings to avoid authentication issues - mock_settings.using_auth = False - mock_settings.on_ha_addon = False - yield mock_settings - - -@pytest.fixture -def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: - """Fixture to mock ext_storage_path.""" - with patch("esphome.dashboard.web_server.ext_storage_path") as mock: - mock.return_value = str(tmp_path / "storage.json") - yield mock - - -@pytest.fixture -def mock_storage_json() -> Generator[MagicMock]: - """Fixture to mock StorageJSON.""" - with patch("esphome.dashboard.web_server.StorageJSON") as mock: - yield mock - - -@pytest.fixture -def mock_idedata() -> Generator[MagicMock]: - """Fixture to mock platformio toolchain.IDEData.""" - with patch("esphome.dashboard.web_server.toolchain.IDEData") as mock: - yield mock - - -@pytest_asyncio.fixture() -async def dashboard() -> DashboardTestHelper: - sock, port = bind_unused_port() - args = Mock( - ha_addon=True, - configuration=get_fixture_path("conf"), - port=port, - ) - DASHBOARD.settings.parse_args(args) - app = web_server.make_app() - http_server = HTTPServer(app) - http_server.add_sockets([sock]) - await DASHBOARD.async_setup() - os.environ["DISABLE_HA_AUTHENTICATION"] = "1" - assert DASHBOARD.settings.using_password is False - assert DASHBOARD.settings.on_ha_addon is True - assert DASHBOARD.settings.using_auth is False - task = asyncio.create_task(DASHBOARD.async_run()) - # Wait for initial device loading to complete - await DASHBOARD.entries.async_request_update_entries() - client = AsyncHTTPClient() - io_loop = IOLoop(make_current=False) - yield DashboardTestHelper(io_loop, client, port) - task.cancel() - sock.close() - client.close() - io_loop.close() - - -@asynccontextmanager -async def websocket_connection(dashboard: DashboardTestHelper): - """Async context manager for WebSocket connections.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - try: - yield ws - finally: - if ws: - ws.close() - - -@pytest_asyncio.fixture -async def websocket_client(dashboard: DashboardTestHelper) -> WebSocketClientConnection: - """Create a WebSocket connection for testing.""" - url = f"ws://127.0.0.1:{dashboard.port}/events" - ws = await websocket_connect(url) - - # Read and discard initial state message - await ws.read_message() - - yield ws - - if ws: - ws.close() - - -@pytest.mark.asyncio -async def test_main_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/") - assert response.code == 200 - - -@pytest.mark.asyncio -async def test_devices_page(dashboard: DashboardTestHelper) -> None: - response = await dashboard.fetch("/devices") - assert response.code == 200 - assert response.headers["content-type"] == "application/json" - json_data = json.loads(response.body.decode()) - configured_devices = json_data["configured"] - assert len(configured_devices) != 0 - first_device = configured_devices[0] - assert first_device["name"] == "pico" - assert first_device["configuration"] == "pico.yaml" - - -@pytest.mark.asyncio -async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post method with invalid inputs.""" - # Test with missing name (should fail with 422) - body_no_name = json.dumps( - { - "name": "", # Empty name - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_no_name, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - # Test with invalid wizard type (should fail with 422) - body_invalid_type = json.dumps( - { - "name": "test_device", - "type": "invalid_type", - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body_invalid_type, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: - """Test the WizardRequestHandler.post when config already exists.""" - # Try to create a wizard for existing pico.yaml (should conflict) - body = json.dumps( - { - "name": "pico", # This already exists in fixtures - "platform": "ESP32", - "board": "esp32dev", - } - ) - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/wizard", - method="POST", - body=body, - headers={"Content-Type": "application/json"}, - ) - assert exc_info.value.code == 409 - - -@pytest.mark.asyncio -async def test_download_binary_handler_not_found( - dashboard: DashboardTestHelper, -) -> None: - """Test the DownloadBinaryRequestHandler.get with non-existent config.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=nonexistent.yaml", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_file_param( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get without file parameter.""" - # Mock storage to exist, but still should fail without file param - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=pico.yaml", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with existing binary file.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"fake firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"fake firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - assert "test_device-firmware.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_compressed( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with compression.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - original_content = b"fake firmware content for compression test" - firmware_file.write_bytes(original_content) - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", - method="GET", - ) - assert response.code == 200 - # Decompress and verify content - decompressed = gzip.decompress(response.body) - assert decompressed == original_content - assert response.headers["Content-Type"] == "application/octet-stream" - assert "firmware.bin.gz" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_custom_download_name( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with custom download name.""" - # Create a fake binary file - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_idedata_fallback( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_async_run_system_command: MagicMock, - mock_storage_json: MagicMock, - mock_idedata: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" - # Create build directory but no bootloader file initially - build_dir = tmp_path / ".esphome" / "build" / "test" - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware") - - # Create bootloader file that idedata will find - bootloader_file = tmp_path / "bootloader.bin" - bootloader_file.write_bytes(b"bootloader content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock idedata response - mock_image = Mock() - mock_image.path = bootloader_file - mock_idedata_instance = Mock() - mock_idedata_instance.extra_flash_images = [mock_image] - mock_idedata.return_value = mock_idedata_instance - - # Mock async_run_system_command to return idedata JSON - mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=bootloader.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"bootloader content" - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). - - This is a regression test for issue #11343 where the Path migration broke - downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. - - The issue was that with_name() doesn't accept path separators: - - Before: path = storage_json.firmware_bin_path.with_name(file_name) - ValueError: Invalid name 'zephyr/zephyr.uf2' - - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) - Works correctly with subdirectory paths - """ - # Create a fake nRF52 build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "nrf52-device") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - # Create the main firmware binary (would be in build root) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main firmware") - - # Create the UF2 file in zephyr subdirectory (nRF52 specific) - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"nRF52 UF2 firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "nrf52-device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request the UF2 file with subdirectory path - response = await dashboard.fetch( - "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nRF52 UF2 firmware content" - assert response.headers["Content-Type"] == "application/octet-stream" - assert "attachment" in response.headers["Content-Disposition"] - # Download name should be device-name + full file path - assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_subdirectory_file_url_encoded( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. - - Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly - decoded and handled, and that custom download names work with subdirectories. - """ - # Create a fake build structure with firmware in subdirectory - build_dir = get_build_path(tmp_path, "test") - zephyr_dir = build_dir / "zephyr" - zephyr_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"content") - - uf2_file = zephyr_dir / "zephyr.uf2" - uf2_file.write_bytes(b"content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Request with URL-encoded path and custom download name - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", - method="GET", - ) - assert response.code == 200 - assert "custom_name.bin" in response.headers["Content-Disposition"] - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize( - ("attack_path", "expected_code"), - [ - pytest.param("../../../secrets.yaml", 403, id="basic_traversal"), - pytest.param("..%2F..%2F..%2Fsecrets.yaml", 403, id="url_encoded"), - pytest.param("zephyr/../../../secrets.yaml", 403, id="traversal_with_prefix"), - pytest.param("/etc/passwd", 403, id="absolute_path"), - pytest.param("//etc/passwd", 403, id="double_slash_absolute"), - pytest.param( - "....//secrets.yaml", - # On Windows, Path.resolve() treats "..." and "...." as parent - # traversal (like ".."), so the path escapes base_dir -> 403. - # On Unix, "...." is a literal directory name that stays inside - # base_dir but doesn't exist -> 404. - 403 if sys.platform == "win32" else 404, - id="multiple_dots", - ), - ], -) -async def test_download_binary_handler_path_traversal_protection( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, - attack_path: str, - expected_code: int, -) -> None: - """Test that DownloadBinaryRequestHandler prevents path traversal attacks. - - Verifies that attempts to escape the build directory via '..' are rejected - using resolve()/relative_to() validation. Tests multiple attack vectors. - Real traversals that escape the base directory get 403. Paths like '....' - that resolve inside the base directory but don't exist get 404. - """ - # Create build structure - build_dir = get_build_path(tmp_path, "test") - build_dir.mkdir(parents=True) - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"firmware content") - - # Create a sensitive file outside the build directory that should NOT be accessible - sensitive_file = tmp_path / "secrets.yaml" - sensitive_file.write_bytes(b"secret: my_secret_password") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - # Mock async_run_system_command so paths that pass validation but don't exist - # return 404 deterministically without spawning a real subprocess. - with ( - patch( - "esphome.dashboard.web_server.async_run_system_command", - new_callable=AsyncMock, - return_value=(2, "", ""), - ), - pytest.raises(HTTPClientError) as exc_info, - ): - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={attack_path}", - method="GET", - ) - assert exc_info.value.code == expected_code - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_no_firmware_bin_path( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, -) -> None: - """Test that download returns 404 when firmware_bin_path is None. - - This covers configs created by StorageJSON.from_wizard() where no - firmware has been compiled yet. - """ - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = None - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=firmware.bin", - method="GET", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -@pytest.mark.parametrize("file_value", ["", "%20%20", "%20"]) -async def test_download_binary_handler_empty_file_name( - dashboard: DashboardTestHelper, - mock_storage_json: MagicMock, - file_value: str, -) -> None: - """Test that download returns 400 for empty or whitespace-only file names.""" - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = Path("/fake/firmware.bin") - mock_storage_json.load.return_value = mock_storage - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - f"/download.bin?configuration=test.yaml&file={file_value}", - method="GET", - ) - assert exc_info.value.code == 400 - - -@pytest.mark.asyncio -@pytest.mark.usefixtures("mock_ext_storage_path") -async def test_download_binary_handler_multiple_subdirectory_levels( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_storage_json: MagicMock, -) -> None: - """Test downloading files from multiple subdirectory levels. - - Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. - """ - # Create nested directory structure - build_dir = get_build_path(tmp_path, "test") - nested_dir = build_dir / "build" / "output" - nested_dir.mkdir(parents=True) - - firmware_file = build_dir / "firmware.bin" - firmware_file.write_bytes(b"main") - - nested_file = nested_dir / "firmware.bin" - nested_file.write_bytes(b"nested firmware content") - - # Mock storage JSON - mock_storage = Mock() - mock_storage.name = "test_device" - mock_storage.firmware_bin_path = firmware_file - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", - method="GET", - ) - assert response.code == 200 - assert response.body == b"nested firmware content" - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_invalid_file( - dashboard: DashboardTestHelper, -) -> None: - """Test the EditRequestHandler.post with non-yaml file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/edit?configuration=test.txt", - method="POST", - body=b"content", - ) - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_edit_request_handler_post_existing( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the EditRequestHandler.post with existing yaml file.""" - # Create a temporary yaml file to edit (don't modify fixtures) - test_file = tmp_path / "test_edit.yaml" - test_file.write_text("esphome:\n name: original\n") - - # Configure the mock settings - mock_dashboard_settings.rel_path.return_value = test_file - mock_dashboard_settings.absolute_config_dir = test_file.parent - - new_content = "esphome:\n name: modified\n" - response = await dashboard.fetch( - "/edit?configuration=test_edit.yaml", - method="POST", - body=new_content.encode(), - ) - assert response.code == 200 - - # Verify the file was actually modified - assert test_file.read_text() == new_content - - -@pytest.mark.asyncio -async def test_unarchive_request_handler( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - tmp_path: Path, -) -> None: - """Test the UnArchiveRequestHandler.post method.""" - # Set up an archived file - archive_dir = mock_archive_storage_path.return_value - archive_dir.mkdir(parents=True, exist_ok=True) - archived_file = archive_dir / "archived.yaml" - archived_file.write_text("test content") - - # Set up the destination path where the file should be moved - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - destination_file = config_dir / "archived.yaml" - mock_dashboard_settings.rel_path.return_value = destination_file - - response = await dashboard.fetch( - "/unarchive?configuration=archived.yaml", - method="POST", - body=b"", - ) - assert response.code == 200 - - # Verify the file was actually moved from archive to config - assert not archived_file.exists() # File should be gone from archive - assert destination_file.exists() # File should now be in config - assert destination_file.read_text() == "test content" # Content preserved - - -@pytest.mark.asyncio -async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: - """Test the SecretKeysRequestHandler.get when no secrets file exists.""" - # By default, there's no secrets file in the test fixtures - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/secret_keys", method="GET") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_secret_keys_handler_with_file( - dashboard: DashboardTestHelper, - tmp_path: Path, - mock_dashboard_settings: MagicMock, -) -> None: - """Test the SecretKeysRequestHandler.get when secrets file exists.""" - # Create a secrets file in temp directory - secrets_file = tmp_path / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" - ) - - # Configure mock to return our temp secrets file - # Since the file actually exists, os.path.isfile will return True naturally - mock_dashboard_settings.rel_path.return_value = secrets_file - - response = await dashboard.fetch("/secret_keys", method="GET") - assert response.code == 200 - data = json.loads(response.body.decode()) - assert "wifi_ssid" in data - assert "wifi_password" in data - assert "api_key" in data - - -@pytest.mark.asyncio -async def test_json_config_handler( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get method.""" - # This will actually run the esphome config command on pico.yaml - mock_output = json.dumps( - { - "esphome": {"name": "pico"}, - "esp32": {"board": "esp32dev"}, - } - ) - mock_async_run_system_command.return_value = (0, mock_output, "") - - response = await dashboard.fetch( - "/json-config?configuration=pico.yaml", method="GET" - ) - assert response.code == 200 - data = json.loads(response.body.decode()) - assert data["esphome"]["name"] == "pico" - - -@pytest.mark.asyncio -async def test_json_config_handler_invalid_config( - dashboard: DashboardTestHelper, - mock_async_run_system_command: MagicMock, -) -> None: - """Test the JsonConfigRequestHandler.get with invalid config.""" - # Simulate esphome config command failure - mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") - - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") - assert exc_info.value.code == 422 - - -@pytest.mark.asyncio -async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: - """Test the JsonConfigRequestHandler.get with non-existent file.""" - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch( - "/json-config?configuration=nonexistent.yaml", method="GET" - ) - assert exc_info.value.code == 404 - - -def test_start_web_server_with_address_port( - tmp_path: Path, - mock_trash_storage_path: MagicMock, - mock_archive_storage_path: MagicMock, -) -> None: - """Test the start_web_server function with address and port.""" - app = Mock() - trash_dir = mock_trash_storage_path.return_value - archive_dir = mock_archive_storage_path.return_value - - # Create trash dir to test migration - trash_dir.mkdir() - (trash_dir / "old.yaml").write_text("old") - - web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) - - # The function calls app.listen directly for non-socket mode - app.listen.assert_called_once_with(6052, "127.0.0.1") - - # Verify trash was moved to archive - assert not trash_dir.exists() - assert archive_dir.exists() - assert (archive_dir / "old.yaml").exists() - - -@pytest.mark.asyncio -async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: - """Test EditRequestHandler.get method.""" - # Test getting a valid yaml file - response = await dashboard.fetch("/edit?configuration=pico.yaml") - assert response.code == 200 - assert response.headers["content-type"] == "application/yaml" - content = response.body.decode() - assert "esphome:" in content # Verify it's a valid ESPHome config - - # Test getting a non-existent file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=nonexistent.yaml") - assert exc_info.value.code == 404 - - # Test getting a non-yaml file - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=test.txt") - assert exc_info.value.code == 404 - - # Test path traversal attempt - with pytest.raises(HTTPClientError) as exc_info: - await dashboard.fetch("/edit?configuration=../../../etc/passwd") - assert exc_info.value.code == 404 - - -@pytest.mark.asyncio -async def test_archive_request_handler_post( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post method without storage_json.""" - - # Set up temp directories - config_dir = Path(get_fixture_path("conf")) - archive_dir = tmp_path / "archive" - - # Create a test configuration file - test_config = config_dir / "test_archive.yaml" - test_config.write_text("esphome:\n name: test_archive\n") - - # Archive the configuration - response = await dashboard.fetch( - "/archive", - method="POST", - body="configuration=test_archive.yaml", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - # Verify file was moved to archive - assert not test_config.exists() - assert (archive_dir / "test_archive.yaml").exists() - assert ( - archive_dir / "test_archive.yaml" - ).read_text() == "esphome:\n name: test_archive\n" - - -@pytest.mark.asyncio -async def test_archive_handler_with_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json and build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - build_dir = tmp_path / "build" - build_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - build_folder = build_dir / "test_device" - build_folder.mkdir() - (build_folder / "firmware.bin").write_text("binary content") - (build_folder / ".pioenvs").mkdir() - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = build_folder - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - - assert not build_folder.exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.asyncio -async def test_archive_handler_no_build_folder( - dashboard: DashboardTestHelper, - mock_archive_storage_path: MagicMock, - mock_ext_storage_path: MagicMock, - mock_dashboard_settings: MagicMock, - mock_storage_json: MagicMock, - tmp_path: Path, -) -> None: - """Test ArchiveRequestHandler.post with storage_json but no build folder.""" - config_dir = tmp_path / "config" - config_dir.mkdir() - archive_dir = tmp_path / "archive" - archive_dir.mkdir() - - configuration = "test_device.yaml" - test_config = config_dir / configuration - test_config.write_text("esphome:\n name: test_device\n") - - mock_dashboard_settings.config_dir = str(config_dir) - mock_dashboard_settings.rel_path.return_value = test_config - mock_archive_storage_path.return_value = archive_dir - - mock_storage = MagicMock() - mock_storage.name = "test_device" - mock_storage.build_path = None - mock_storage_json.load.return_value = mock_storage - - response = await dashboard.fetch( - "/archive", - method="POST", - body=f"configuration={configuration}", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - assert response.code == 200 - - assert not test_config.exists() - assert (archive_dir / configuration).exists() - assert not (archive_dir / "test_device").exists() - - -@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") -@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") -def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: - """Test the start_web_server function with unix socket.""" - app = Mock() - socket_path = tmp_path / "test.sock" - - # Don't create trash_dir - it doesn't exist, so no migration needed - with ( - patch("tornado.httpserver.HTTPServer") as mock_server_class, - patch("tornado.netutil.bind_unix_socket") as mock_bind, - ): - server = Mock() - mock_server_class.return_value = server - mock_bind.return_value = Mock() - - web_server.start_web_server( - app, str(socket_path), None, None, str(tmp_path / "config") - ) - - mock_server_class.assert_called_once_with(app) - mock_bind.assert_called_once_with(str(socket_path), mode=0o666) - server.add_socket.assert_called_once() - - -def test_build_cache_arguments_no_entry(mock_dashboard: Mock) -> None: - """Test with no entry returns empty list.""" - result = web_server.build_cache_arguments(None, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_no_address_no_name(mock_dashboard: Mock) -> None: - """Test with entry but no address or name.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = None - entry.name = None - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - assert result == [] - - -def test_build_cache_arguments_mdns_address_cached(mock_dashboard: Mock) -> None: - """Test with .local address that has cached mDNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "device.local" - entry.name = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = [ - "192.168.1.10", - "fe80::1", - ] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "device.local=192.168.1.10,fe80::1", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "device.local" - ) - - -def test_build_cache_arguments_dns_address_cached(mock_dashboard: Mock) -> None: - """Test with non-.local address that has cached DNS results.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.address = "example.com" - entry.name = None - mock_dashboard.dns_cache = Mock() - mock_dashboard.dns_cache.get_cached_addresses.return_value = [ - "93.184.216.34", - "2606:2800:220:1:248:1893:25c8:1946", - ] - - now = 100.0 - result = web_server.build_cache_arguments(entry, mock_dashboard, now) - - # IPv6 addresses are sorted before IPv4 - assert result == [ - "--dns-address-cache", - "example.com=2606:2800:220:1:248:1893:25c8:1946,93.184.216.34", - ] - mock_dashboard.dns_cache.get_cached_addresses.assert_called_once_with( - "example.com", now - ) - - -def test_build_cache_arguments_name_without_address(mock_dashboard: Mock) -> None: - """Test with name but no address - should check mDNS with .local suffix.""" - entry = Mock(spec=web_server.DashboardEntry) - entry.name = "my-device" - entry.address = None - mock_dashboard.mdns_status = Mock() - mock_dashboard.mdns_status.get_cached_addresses.return_value = ["192.168.1.20"] - - result = web_server.build_cache_arguments(entry, mock_dashboard, 0.0) - - assert result == [ - "--mdns-address-cache", - "my-device.local=192.168.1.20", - ] - mock_dashboard.mdns_status.get_cached_addresses.assert_called_once_with( - "my-device.local" - ) - - -@pytest.mark.asyncio -async def test_websocket_connection_initial_state( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection and initial state.""" - async with websocket_connection(dashboard) as ws: - # Should receive initial state with configured and importable devices - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - assert "devices" in data["data"] - assert "configured" in data["data"]["devices"] - assert "importable" in data["data"]["devices"] - - # Check configured devices - configured = data["data"]["devices"]["configured"] - assert len(configured) > 0 - assert configured[0]["name"] == "pico" # From test fixtures - - -@pytest.mark.asyncio -async def test_websocket_ping_pong( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket ping/pong mechanism.""" - # Send ping - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_invalid_json( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket handling of invalid JSON.""" - # Send invalid JSON - await websocket_client.write_message("not valid json {]") - - # Send a valid ping to verify connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should receive pong, confirming the connection wasn't closed by invalid JSON - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_authentication_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket authentication when auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = False - - # Try to connect - should be rejected with 401 - url = f"ws://127.0.0.1:{dashboard.port}/events" - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(url) - # Should get HTTP 401 Unauthorized - assert exc_info.value.code == 401 - - -@pytest.mark.asyncio -async def test_websocket_authentication_not_required( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket connection when no auth is required.""" - with patch( - "esphome.dashboard.web_server.is_authenticated" - ) as mock_is_authenticated: - mock_is_authenticated.return_value = True - - # Should be able to connect successfully - async with websocket_connection(dashboard) as ws: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - - -@pytest.mark.asyncio -async def test_websocket_entry_state_changed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry state changed event.""" - # Simulate entry state change - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(True, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - # Should receive state change event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_state_changed" - assert data["data"]["filename"] == entry.filename - assert data["data"]["name"] == entry.name - assert data["data"]["state"] is True - - -@pytest.mark.asyncio -async def test_websocket_entry_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry added event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "test.yaml" - mock_entry.name = "test_device" - mock_entry.to_dict.return_value = { - "name": "test_device", - "filename": "test.yaml", - "configuration": "test.yaml", - } - - # Simulate entry added - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_ADDED, {"entry": mock_entry}) - - # Should receive entry added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_added" - assert data["data"]["device"]["name"] == "test_device" - assert data["data"]["device"]["filename"] == "test.yaml" - - -@pytest.mark.asyncio -async def test_websocket_entry_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket entry removed event.""" - # Create a mock entry - mock_entry = Mock(spec=DashboardEntry) - mock_entry.filename = "removed.yaml" - mock_entry.name = "removed_device" - mock_entry.to_dict.return_value = { - "name": "removed_device", - "filename": "removed.yaml", - "configuration": "removed.yaml", - } - - # Simulate entry removed - DASHBOARD.bus.async_fire(DashboardEvent.ENTRY_REMOVED, {"entry": mock_entry}) - - # Should receive entry removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "entry_removed" - assert data["data"]["device"]["name"] == "removed_device" - assert data["data"]["device"]["filename"] == "removed.yaml" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event with real DiscoveredImport.""" - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="new_import_device", - friendly_name="New Import Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="wifi", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "new_import_device" - assert data["data"]["device"]["friendly_name"] == "New Import Device" - assert data["data"]["device"]["project_name"] == "test_project" - assert data["data"]["device"]["network"] == "wifi" - assert data["data"]["device"]["ignored"] is False - - -@pytest.mark.asyncio -async def test_websocket_importable_device_added_ignored( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device added event for ignored device.""" - # Add device to ignored list - DASHBOARD.ignored_devices.add("ignored_device") - - # Create a real DiscoveredImport object - discovered = DiscoveredImport( - device_name="ignored_device", - friendly_name="Ignored Device", - package_import_url="https://example.com/package", - project_name="test_project", - project_version="1.0.0", - network="ethernet", - ) - - # Directly fire the event as the mDNS system would - device_dict = build_importable_device_dict(DASHBOARD, discovered) - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, {"device": device_dict} - ) - - # Should receive importable device added event with ignored=True - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_added" - assert data["data"]["device"]["name"] == "ignored_device" - assert data["data"]["device"]["friendly_name"] == "Ignored Device" - assert data["data"]["device"]["network"] == "ethernet" - assert data["data"]["device"]["ignored"] is True - - -@pytest.mark.asyncio -async def test_websocket_importable_device_removed( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket importable device removed event.""" - # Simulate importable device removed - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_REMOVED, - {"name": "removed_import_device"}, - ) - - # Should receive importable device removed event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "importable_device_removed" - assert data["data"]["name"] == "removed_import_device" - - -@pytest.mark.asyncio -async def test_websocket_importable_device_already_configured( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test that importable device event is not sent if device is already configured.""" - # Get an existing configured device name - existing_entry = DASHBOARD.entries.async_all()[0] - - # Simulate importable device added with same name as configured device - DASHBOARD.bus.async_fire( - DashboardEvent.IMPORTABLE_DEVICE_ADDED, - { - "device": { - "name": existing_entry.name, - "friendly_name": "Should Not Be Sent", - "package_import_url": "https://example.com/package", - "project_name": "test_project", - "project_version": "1.0.0", - "network": "wifi", - } - }, - ) - - # Send a ping to ensure connection is still alive - await websocket_client.write_message(json.dumps({"event": "ping"})) - - # Should only receive pong, not the importable device event - msg = await websocket_client.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "pong" - - -@pytest.mark.asyncio -async def test_websocket_multiple_connections(dashboard: DashboardTestHelper) -> None: - """Test multiple WebSocket connections.""" - async with ( - websocket_connection(dashboard) as ws1, - websocket_connection(dashboard) as ws2, - ): - # Both should receive initial state - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "initial_state" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "initial_state" - - # Fire an event - both should receive it - entry = DASHBOARD.entries.async_all()[0] - state = bool_to_entry_state(False, EntryStateSource.MDNS) - DASHBOARD.bus.async_fire( - DashboardEvent.ENTRY_STATE_CHANGED, {"entry": entry, "state": state} - ) - - msg1 = await ws1.read_message() - assert msg1 is not None - data1 = json.loads(msg1) - assert data1["event"] == "entry_state_changed" - - msg2 = await ws2.read_message() - assert msg2 is not None - data2 = json.loads(msg2) - assert data2["event"] == "entry_state_changed" - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_lifecycle(dashboard: DashboardTestHelper) -> None: - """Test DashboardSubscriber lifecycle.""" - subscriber = DashboardSubscriber() - - # Initially no subscribers - assert len(subscriber._subscribers) == 0 - assert subscriber._event_loop_task is None - - # Add a subscriber - mock_websocket = Mock() - unsubscribe = subscriber.subscribe(mock_websocket) - - # Should have started the event loop task - assert len(subscriber._subscribers) == 1 - assert subscriber._event_loop_task is not None - - # Unsubscribe - unsubscribe() - - # Should have stopped the task - assert len(subscriber._subscribers) == 0 - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_entries_update_interval( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber entries update interval.""" - # Patch the constants to make the test run faster - with ( - patch("esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 0.01), - patch("esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 2), - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = Mock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait for a few iterations to ensure entries update is called - await asyncio.sleep(0.05) # Should be enough for 2+ iterations - - # Unsubscribe to stop the task - unsubscribe() - - # Verify entries update was called - assert mock_dashboard.entries.async_request_update_entries.call_count >= 1 - # Verify ping request was set multiple times - assert mock_dashboard.ping_request.set.call_count >= 2 - - -@pytest.mark.asyncio -async def test_websocket_refresh_command( - dashboard: DashboardTestHelper, websocket_client: WebSocketClientConnection -) -> None: - """Test WebSocket refresh command triggers dashboard update.""" - with patch("esphome.dashboard.web_server.DASHBOARD_SUBSCRIBER") as mock_subscriber: - # Signal an asyncio.Event when request_refresh is invoked so the - # test can deterministically wait for the server-side handler to run - # instead of relying on a fixed sleep (flaky on Windows CI under load). - called = asyncio.Event() - mock_subscriber.request_refresh = Mock(side_effect=called.set) - - # Send refresh command - await websocket_client.write_message(json.dumps({"event": "refresh"})) - - # Wait for the server to process the message and invoke request_refresh - async with asyncio.timeout(5): - await called.wait() - - # Verify request_refresh was called - mock_subscriber.request_refresh.assert_called_once() - - -@pytest.mark.asyncio -async def test_dashboard_subscriber_refresh_event( - dashboard: DashboardTestHelper, -) -> None: - """Test DashboardSubscriber refresh event triggers immediate update.""" - # Patch the constants to make the test run faster - with ( - patch( - "esphome.dashboard.web_server.DASHBOARD_POLL_INTERVAL", 1.0 - ), # Long timeout - patch( - "esphome.dashboard.web_server.DASHBOARD_ENTRIES_UPDATE_ITERATIONS", 100 - ), # Won't reach naturally - patch("esphome.dashboard.web_server.settings") as mock_settings, - patch("esphome.dashboard.web_server.DASHBOARD") as mock_dashboard, - ): - mock_settings.status_use_mqtt = False - - # Mock dashboard dependencies - mock_dashboard.ping_request = Mock() - mock_dashboard.ping_request.set = Mock() - mock_dashboard.entries = Mock() - mock_dashboard.entries.async_request_update_entries = AsyncMock() - - subscriber = DashboardSubscriber() - mock_websocket = Mock() - - # Subscribe to start the event loop - unsubscribe = subscriber.subscribe(mock_websocket) - - # Wait a bit to ensure loop is running - await asyncio.sleep(0.01) - - # Verify entries update hasn't been called yet (iterations not reached) - assert mock_dashboard.entries.async_request_update_entries.call_count == 0 - - # Request refresh - subscriber.request_refresh() - - # Wait for the refresh to be processed - await asyncio.sleep(0.01) - - # Now entries update should have been called - assert mock_dashboard.entries.async_request_update_entries.call_count == 1 - - # Unsubscribe to stop the task - unsubscribe() - - # Give it a moment to clean up - await asyncio.sleep(0.01) - - -@pytest.mark.asyncio -async def test_dashboard_yaml_loading_with_packages_and_secrets( - tmp_path: Path, -) -> None: - """Test dashboard YAML loading with packages referencing secrets. - - This is a regression test for issue #11280 where binary download failed - when using packages with secrets after the Path migration in 2025.10.0. - - This test verifies that CORE.config_path initialization in the dashboard - allows yaml_util.load_yaml() to correctly resolve secrets from packages. - """ - # Create test directory structure with secrets and packages - config_dir = tmp_path / "config" - config_dir.mkdir() - - # Create secrets.yaml with obviously fake test values - secrets_file = config_dir / "secrets.yaml" - secrets_file.write_text( - "wifi_ssid: TEST-DUMMY-SSID\n" - "wifi_password: not-a-real-password-just-for-testing\n" - ) - - # Create package file that uses secrets - package_file = config_dir / "common.yaml" - package_file.write_text( - "wifi:\n ssid: !secret wifi_ssid\n password: !secret wifi_password\n" - ) - - # Create main device config that includes the package - device_config = config_dir / "test-download-secrets.yaml" - device_config.write_text( - "esphome:\n name: test-download-secrets\n platform: ESP32\n board: esp32dev\n\n" - "packages:\n common: !include common.yaml\n" - ) - - # Initialize DASHBOARD settings with our test config directory - # This is what sets CORE.config_path - the critical code path for the bug - args = Namespace( - configuration=str(config_dir), - password=None, - username=None, - ha_addon=False, - verbose=False, - ) - DASHBOARD.settings.parse_args(args) - - # With the fix: CORE.config_path should be config_dir / "___DASHBOARD_SENTINEL___.yaml" - # so CORE.config_path.parent would be config_dir - # Without the fix: CORE.config_path is config_dir / "." which normalizes to config_dir - # so CORE.config_path.parent would be tmp_path (the parent of config_dir) - - # The fix ensures CORE.config_path.parent points to config_dir - assert CORE.config_path.parent == config_dir.resolve(), ( - f"CORE.config_path.parent should point to config_dir. " - f"Got {CORE.config_path.parent}, expected {config_dir.resolve()}. " - f"CORE.config_path is {CORE.config_path}" - ) - - # Now load the YAML with packages that reference secrets - # This is where the bug would manifest - yaml_util.load_yaml would fail - # to find secrets.yaml because CORE.config_path.parent pointed to the wrong place - config = yaml_util.load_yaml(device_config) - # If we get here, secret resolution worked! - assert "esphome" in config - assert config["esphome"]["name"] == "test-download-secrets" - - -@pytest.mark.asyncio -async def test_websocket_check_origin_default_same_origin( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" - # Ensure ESPHOME_TRUSTED_DOMAINS is not set - env = os.environ.copy() - env.pop("ESPHOME_TRUSTED_DOMAINS", None) - with patch.dict(os.environ, env, clear=True): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Same origin should work (default Tornado behavior) - request = HTTPRequest( - url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} - ) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_trusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from trusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) - ws = await websocket_connect(request) - try: - # Should receive initial state - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -@pytest.mark.asyncio -async def test_websocket_check_origin_untrusted_domain( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket rejects connections from untrusted domains.""" - with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) - with pytest.raises(HTTPClientError) as exc_info: - await websocket_connect(request) - # Should get HTTP 403 Forbidden due to origin check failure - assert exc_info.value.code == 403 - - -@pytest.mark.asyncio -async def test_websocket_check_origin_multiple_trusted_domains( - dashboard: DashboardTestHelper, -) -> None: - """Test WebSocket accepts connections from multiple trusted domains.""" - with patch.dict( - os.environ, - {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, - ): - from tornado.httpclient import HTTPRequest - - url = f"ws://127.0.0.1:{dashboard.port}/events" - # Test second domain in list (with space after comma) - request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) - ws = await websocket_connect(request) - try: - msg = await ws.read_message() - assert msg is not None - data = json.loads(msg) - assert data["event"] == "initial_state" - finally: - ws.close() - - -def test_proc_on_exit_calls_close() -> None: - """Test _proc_on_exit sends exit event and closes the WebSocket.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = False - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_called_once_with({"event": "exit", "code": 0}) - handler.close.assert_called_once() - - -def test_proc_on_exit_skips_when_already_closed() -> None: - """Test _proc_on_exit does nothing when WebSocket is already closed.""" - handler = Mock(spec=EsphomeCommandWebSocket) - handler._is_closed = True - - EsphomeCommandWebSocket._proc_on_exit(handler, 0) - - handler.write_message.assert_not_called() - handler.close.assert_not_called() - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_appends_no_states_when_set() -> None: - """Test --no-states is appended when no_states is truthy in the message.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - json_message = { - "configuration": "device.yaml", - "port": "OTA", - "no_states": True, - } - cmd = await web_server.EsphomeLogsHandler.build_command(handler, json_message) - - assert cmd == [ - "esphome", - "logs", - "device.yaml", - "--device", - "OTA", - "--no-states", - ] - handler.build_device_command.assert_awaited_once_with(["logs"], json_message) - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_omits_no_states_when_missing() -> None: - """Test --no-states is not added when no_states is absent from the message.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - cmd = await web_server.EsphomeLogsHandler.build_command( - handler, {"configuration": "device.yaml", "port": "OTA"} - ) - - assert "--no-states" not in cmd - assert cmd == ["esphome", "logs", "device.yaml", "--device", "OTA"] - - -@pytest.mark.asyncio -async def test_esphome_logs_handler_omits_no_states_when_false() -> None: - """Test --no-states is not added when no_states is explicitly False.""" - handler = Mock(spec=web_server.EsphomeLogsHandler) - handler.build_device_command = AsyncMock( - return_value=["esphome", "logs", "device.yaml", "--device", "OTA"] - ) - - cmd = await web_server.EsphomeLogsHandler.build_command( - handler, - {"configuration": "device.yaml", "port": "OTA", "no_states": False}, - ) - - assert "--no-states" not in cmd - - -def _make_auth_handler(auth_header: str | None = None) -> Mock: - """Create a mock handler with the given Authorization header.""" - handler = Mock() - handler.request = Mock() - if auth_header is not None: - handler.request.headers = {"Authorization": auth_header} - else: - handler.request.headers = {} - handler.get_secure_cookie = Mock(return_value=None) - return handler - - -@pytest.fixture -def mock_auth_settings(mock_dashboard_settings: MagicMock) -> MagicMock: - """Fixture to configure mock dashboard settings with auth enabled.""" - mock_dashboard_settings.using_auth = True - mock_dashboard_settings.on_ha_addon = False - return mock_dashboard_settings - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_malformed_base64() -> None: - """Test that invalid base64 in Authorization header returns False.""" - handler = _make_auth_handler("Basic !!!not-valid-base64!!!") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_bad_base64_padding() -> None: - """Test that incorrect base64 padding (binascii.Error) returns False.""" - handler = _make_auth_handler("Basic abc") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_invalid_utf8() -> None: - """Test that base64 decoding to invalid UTF-8 returns False.""" - # \xff\xfe is invalid UTF-8 - bad_payload = base64.b64encode(b"\xff\xfe").decode("ascii") - handler = _make_auth_handler(f"Basic {bad_payload}") - assert web_server.is_authenticated(handler) is False - - -@pytest.mark.usefixtures("mock_auth_settings") -def test_is_authenticated_no_colon() -> None: - """Test that base64 payload without ':' separator returns False.""" - no_colon = base64.b64encode(b"nocolonhere").decode("ascii") - handler = _make_auth_handler(f"Basic {no_colon}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_valid_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth credentials are checked.""" - creds = base64.b64encode(b"admin:secret").decode("ascii") - mock_auth_settings.check_password.return_value = True - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is True - mock_auth_settings.check_password.assert_called_once_with("admin", "secret") - - -def test_is_authenticated_wrong_credentials( - mock_auth_settings: MagicMock, -) -> None: - """Test that valid Basic auth with wrong credentials returns False.""" - creds = base64.b64encode(b"admin:wrong").decode("ascii") - mock_auth_settings.check_password.return_value = False - handler = _make_auth_handler(f"Basic {creds}") - assert web_server.is_authenticated(handler) is False - - -def test_is_authenticated_no_auth_configured( - mock_dashboard_settings: MagicMock, -) -> None: - """Test that requests pass when auth is not configured.""" - mock_dashboard_settings.using_auth = False - mock_dashboard_settings.on_ha_addon = False - handler = _make_auth_handler() - assert web_server.is_authenticated(handler) is True diff --git a/tests/dashboard/test_web_server_paths.py b/tests/dashboard/test_web_server_paths.py deleted file mode 100644 index efeafbf3b5..0000000000 --- a/tests/dashboard/test_web_server_paths.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Tests for dashboard web_server Path-related functionality.""" - -from __future__ import annotations - -import gzip -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -from esphome.dashboard import web_server - - -def test_get_base_frontend_path_production() -> None: - """Test get_base_frontend_path in production mode.""" - mock_module = MagicMock() - mock_module.where.return_value = Path("/usr/local/lib/esphome_dashboard") - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_base_frontend_path() - assert result == Path("/usr/local/lib/esphome_dashboard") - mock_module.where.assert_called_once() - - -def test_get_base_frontend_path_dev_mode() -> None: - """Test get_base_frontend_path in development mode.""" - test_path = "/home/user/esphome/dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_with_trailing_slash() -> None: - """Test get_base_frontend_path in dev mode with trailing slash.""" - test_path = "/home/user/esphome/dashboard/" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - expected = (Path.cwd() / test_path / "esphome_dashboard").resolve() - assert result == expected - - -def test_get_base_frontend_path_dev_mode_relative_path() -> None: - """Test get_base_frontend_path with relative dev path.""" - test_path = "./dashboard" - - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": test_path}): - result = web_server.get_base_frontend_path() - - # The function uses Path.resolve() which resolves symlinks - # The actual function adds "/" to the path, so we simulate that - test_path_with_slash = test_path if test_path.endswith("/") else test_path + "/" - expected = (Path.cwd() / test_path_with_slash / "esphome_dashboard").resolve() - assert result == expected - assert result.is_absolute() - - -def test_get_static_path_single_component() -> None: - """Test get_static_path with single path component.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("file.js") - - assert result == Path("/base/frontend") / "static" / "file.js" - - -def test_get_static_path_multiple_components() -> None: - """Test get_static_path with multiple path components.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js", "esphome", "index.js") - - assert ( - result == Path("/base/frontend") / "static" / "js" / "esphome" / "index.js" - ) - - -def test_get_static_path_empty_args() -> None: - """Test get_static_path with no arguments.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path() - - assert result == Path("/base/frontend") / "static" - - -def test_get_static_path_with_pathlib_path() -> None: - """Test get_static_path with Path objects.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - path_obj = Path("js") / "app.js" - result = web_server.get_static_path(str(path_obj)) - - assert result == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_get_static_file_url_production() -> None: - """Test get_static_file_url in production mode.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_path = MagicMock(spec=Path) - mock_path.read_bytes.return_value = b"test content" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - patch("esphome.dashboard.web_server.get_static_path") as mock_get_path, - ): - mock_get_path.return_value = mock_path - result = web_server.get_static_file_url("js/app.js") - assert result.startswith("./static/js/app.js?hash=") - - -def test_get_static_file_url_dev_mode() -> None: - """Test get_static_file_url in development mode.""" - with patch.dict(os.environ, {"ESPHOME_DASHBOARD_DEV": "/dev/path"}): - web_server.get_static_file_url.cache_clear() - result = web_server.get_static_file_url("js/app.js") - - assert result == "./static/js/app.js" - - -def test_get_static_file_url_index_js_special_case() -> None: - """Test get_static_file_url replaces index.js with entrypoint.""" - web_server.get_static_file_url.cache_clear() - mock_module = MagicMock() - mock_module.entrypoint.return_value = "main.js" - - with ( - patch.dict(os.environ, {}, clear=True), - patch.dict("sys.modules", {"esphome_dashboard": mock_module}), - ): - result = web_server.get_static_file_url("js/esphome/index.js") - assert result == "./static/js/esphome/main.js" - - -def test_load_file_path(tmp_path: Path) -> None: - """Test loading a file.""" - test_file = tmp_path / "test.txt" - test_file.write_bytes(b"test content") - - with test_file.open("rb") as f: - content = f.read() - assert content == b"test content" - - -def test_load_file_compressed_path(tmp_path: Path) -> None: - """Test loading a compressed file.""" - test_file = tmp_path / "test.txt.gz" - - with gzip.open(test_file, "wb") as gz: - gz.write(b"compressed content") - - with gzip.open(test_file, "rb") as gz: - content = gz.read() - assert content == b"compressed content" - - -def test_path_normalization_in_static_path() -> None: - """Test that paths are normalized correctly.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - # Test with separate components - result1 = web_server.get_static_path("js", "app.js") - result2 = web_server.get_static_path("js", "app.js") - - assert result1 == result2 - assert result1 == Path("/base/frontend") / "static" / "js" / "app.js" - - -def test_windows_path_handling() -> None: - """Test handling of Windows-style paths.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path(r"C:\Program Files\esphome\frontend") - - result = web_server.get_static_path("js", "app.js") - - # Path should handle this correctly on the platform - expected = ( - Path(r"C:\Program Files\esphome\frontend") / "static" / "js" / "app.js" - ) - assert result == expected - - -def test_path_with_special_characters() -> None: - """Test paths with special characters.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/frontend") - - result = web_server.get_static_path("js-modules", "app_v1.0.js") - - assert ( - result == Path("/base/frontend") / "static" / "js-modules" / "app_v1.0.js" - ) - - -def test_path_with_spaces() -> None: - """Test paths with spaces.""" - with patch("esphome.dashboard.web_server.get_base_frontend_path") as mock_base: - mock_base.return_value = Path("/base/my frontend") - - result = web_server.get_static_path("my js", "my app.js") - - assert result == Path("/base/my frontend") / "static" / "my js" / "my app.js" diff --git a/tests/dashboard/util/__init__.py b/tests/dashboard/util/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml new file mode 100644 index 0000000000..26791e2cd1 --- /dev/null +++ b/tests/integration/fixtures/api_homeassistant_action_no_subscriber.yaml @@ -0,0 +1,30 @@ +esphome: + name: test-ha-action-no-subscriber + friendly_name: Home Assistant Action No Subscriber Test + on_boot: + # Fires before any client is connected - dropped with a warning. + - homeassistant.action: + action: test.boot_action + +host: + +api: + on_client_connected: + # Fires at authentication time, before the client has subscribed to + # Home Assistant actions - dropped with a warning. + - homeassistant.action: + action: test.connected_action + +logger: + level: DEBUG + +button: + - platform: template + name: Send Action Button + id: send_action_button + on_press: + # Pressed only after the client has subscribed - must be delivered. + - homeassistant.action: + action: test.button_action + data: + value: subscribed diff --git a/tests/integration/fixtures/api_zero_psk_provisioning.yaml b/tests/integration/fixtures/api_zero_psk_provisioning.yaml new file mode 100644 index 0000000000..1bb2a43e71 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-provision-test +host: +api: + encryption: +logger: diff --git a/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml new file mode 100644 index 0000000000..a798c038d7 --- /dev/null +++ b/tests/integration/fixtures/api_zero_psk_provisioning_plaintext.yaml @@ -0,0 +1,6 @@ +esphome: + name: zero-psk-plaintext-test +host: +api: + encryption: +logger: diff --git a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp index f6fd1b1de7..d419694af7 100644 --- a/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_bulk_cleanup_component/scheduler_bulk_cleanup_component.cpp @@ -8,14 +8,23 @@ static const char *const TAG = "bulk_cleanup"; void SchedulerBulkCleanupComponent::setup() { ESP_LOGI(TAG, "Scheduler bulk cleanup test component loaded"); } +// Static name tables keep the const char* pointers valid for the lifetime of the scheduled tasks. +static const char *const BULK_TIMEOUT_NAMES[25] = { + "bulk_timeout_0", "bulk_timeout_1", "bulk_timeout_2", "bulk_timeout_3", "bulk_timeout_4", + "bulk_timeout_5", "bulk_timeout_6", "bulk_timeout_7", "bulk_timeout_8", "bulk_timeout_9", + "bulk_timeout_10", "bulk_timeout_11", "bulk_timeout_12", "bulk_timeout_13", "bulk_timeout_14", + "bulk_timeout_15", "bulk_timeout_16", "bulk_timeout_17", "bulk_timeout_18", "bulk_timeout_19", + "bulk_timeout_20", "bulk_timeout_21", "bulk_timeout_22", "bulk_timeout_23", "bulk_timeout_24"}; +static const char *const POST_CLEANUP_NAMES[5] = {"post_cleanup_0", "post_cleanup_1", "post_cleanup_2", + "post_cleanup_3", "post_cleanup_4"}; + void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { ESP_LOGI(TAG, "Starting bulk cleanup test..."); // Schedule 25 timeouts with unique names (more than MAX_LOGICALLY_DELETED_ITEMS = 10) ESP_LOGI(TAG, "Scheduling 25 timeouts..."); for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 2500, [i]() { + App.scheduler.set_timeout(this, BULK_TIMEOUT_NAMES[i], 2500, [i]() { // These should never execute as we'll cancel them ESP_LOGW(TAG, "Timeout %d executed - this should not happen!", i); }); @@ -24,8 +33,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Cancel all of them to mark for removal ESP_LOGI(TAG, "Cancelling all 25 timeouts to trigger bulk cleanup..."); int cancelled_count = 0; - for (int i = 0; i < 25; i++) { - std::string name = "bulk_timeout_" + std::to_string(i); + for (const char *name : BULK_TIMEOUT_NAMES) { if (App.scheduler.cancel_timeout(this, name)) { cancelled_count++; } @@ -56,8 +64,7 @@ void SchedulerBulkCleanupComponent::trigger_bulk_cleanup() { // Also schedule some normal timeouts to ensure scheduler keeps working after cleanup static int post_cleanup_count = 0; for (int i = 0; i < 5; i++) { - std::string name = "post_cleanup_" + std::to_string(i); - App.scheduler.set_timeout(this, name, 50 + i * 25, [i]() { + App.scheduler.set_timeout(this, POST_CLEANUP_NAMES[i], 50 + i * 25, [i]() { ESP_LOGI(TAG, "Post-cleanup timeout %d executed correctly", i); post_cleanup_count++; if (post_cleanup_count >= 5) { diff --git a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp index 0e5525d265..4971a15dbc 100644 --- a/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_rapid_cancellation_component/rapid_cancellation_component.cpp @@ -4,12 +4,18 @@ #include #include #include -#include namespace esphome::scheduler_rapid_cancellation_component { static const char *const TAG = "scheduler_rapid_cancellation"; +// Static name table keeps the const char* pointers valid for the lifetime of the scheduled tasks. +// Threads race over this fixed set of names; STATIC_STRING names match by content, so scheduling +// the same name replaces (implicitly cancels) the previous timeout, exactly as before. +static const char *const SHARED_TIMEOUT_NAMES[10] = { + "shared_timeout_0", "shared_timeout_1", "shared_timeout_2", "shared_timeout_3", "shared_timeout_4", + "shared_timeout_5", "shared_timeout_6", "shared_timeout_7", "shared_timeout_8", "shared_timeout_9"}; + void SchedulerRapidCancellationComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerRapidCancellationComponent setup"); } void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { @@ -32,14 +38,12 @@ void SchedulerRapidCancellationComponent::run_rapid_cancellation_test() { for (int i = 0; i < OPERATIONS_PER_THREAD; i++) { // Use modulo to ensure multiple threads use the same names int name_index = i % NUM_NAMES; - std::stringstream ss; - ss << "shared_timeout_" << name_index; - std::string name = ss.str(); + const char *name = SHARED_TIMEOUT_NAMES[name_index]; // All threads schedule timeouts - this will implicitly cancel existing ones this->set_timeout(name, 150, [this, name]() { this->total_executed_.fetch_add(1); - ESP_LOGI(TAG, "Executed callback '%s'", name.c_str()); + ESP_LOGI(TAG, "Executed callback '%s'", name); }); this->total_scheduled_.fetch_add(1); diff --git a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp index a817b9f508..a3d135527f 100644 --- a/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp +++ b/tests/integration/fixtures/external_components/scheduler_simultaneous_callbacks_component/simultaneous_callbacks_component.cpp @@ -1,9 +1,9 @@ #include "simultaneous_callbacks_component.h" #include "esphome/core/log.h" +#include #include #include #include -#include namespace esphome::scheduler_simultaneous_callbacks_component { @@ -41,13 +41,11 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() std::this_thread::sleep_until(start_time + std::chrono::microseconds(100)); for (int i = 0; i < CALLBACKS_PER_THREAD; i++) { - // Create unique name for each callback - std::stringstream ss; - ss << "thread_" << thread_id << "_cb_" << i; - std::string name = ss.str(); + // Unique numeric ID for each callback (zero heap allocation, no name collisions) + uint32_t callback_id = static_cast(thread_id) * CALLBACKS_PER_THREAD + i; // Schedule callback for exactly DELAY_MS from now - this->set_timeout(name, DELAY_MS, [this, name]() { + this->set_timeout(callback_id, DELAY_MS, [this, callback_id]() { // Increment concurrent counter atomically int current = this->callbacks_at_once_.fetch_add(1) + 1; @@ -57,7 +55,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() // Loop until we successfully update or someone else set a higher value } - ESP_LOGV(TAG, "Callback executed: %s (concurrent: %d)", name.c_str(), current); + ESP_LOGV(TAG, "Callback executed: id=%" PRIu32 " (concurrent: %d)", callback_id, current); // Simulate some minimal work std::atomic work{0}; @@ -73,7 +71,7 @@ void SchedulerSimultaneousCallbacksComponent::run_simultaneous_callbacks_test() }); this->total_scheduled_.fetch_add(1); - ESP_LOGV(TAG, "Scheduled callback %s", name.c_str()); + ESP_LOGV(TAG, "Scheduled callback id=%" PRIu32, callback_id); } ESP_LOGD(TAG, "Thread %d completed scheduling", thread_id); diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py deleted file mode 100644 index 3f29a839ef..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_lifetime_component_ns = cg.esphome_ns.namespace( - "scheduler_string_lifetime_component" -) -SchedulerStringLifetimeComponent = scheduler_string_lifetime_component_ns.class_( - "SchedulerStringLifetimeComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringLifetimeComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp deleted file mode 100644 index cc1b9f7814..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.cpp +++ /dev/null @@ -1,260 +0,0 @@ -#include "string_lifetime_component.h" -#include "esphome/core/log.h" -#include -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -static const char *const TAG = "scheduler_string_lifetime"; - -void SchedulerStringLifetimeComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringLifetimeComponent setup"); } - -void SchedulerStringLifetimeComponent::run_string_lifetime_test() { - ESP_LOGI(TAG, "Starting string lifetime tests"); - - this->tests_passed_ = 0; - this->tests_failed_ = 0; - - // Run each test - test_temporary_string_lifetime(); - test_scope_exit_string(); - test_vector_reallocation(); - test_string_move_semantics(); - test_lambda_capture_lifetime(); -} - -void SchedulerStringLifetimeComponent::run_test1() { - test_temporary_string_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test1_complete", 10, []() { ESP_LOGI(TAG, "Test 1 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test2() { - test_scope_exit_string(); - // Wait for all callbacks to execute - this->set_timeout("test2_complete", 20, []() { ESP_LOGI(TAG, "Test 2 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test3() { - test_vector_reallocation(); - // Wait for all callbacks to execute - this->set_timeout("test3_complete", 60, []() { ESP_LOGI(TAG, "Test 3 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test4() { - test_string_move_semantics(); - // Wait for all callbacks to execute - this->set_timeout("test4_complete", 35, []() { ESP_LOGI(TAG, "Test 4 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_test5() { - test_lambda_capture_lifetime(); - // Wait for all callbacks to execute - this->set_timeout("test5_complete", 50, []() { ESP_LOGI(TAG, "Test 5 complete"); }); -} - -void SchedulerStringLifetimeComponent::run_final_check() { - ESP_LOGI(TAG, "Tests passed: %d", this->tests_passed_); - ESP_LOGI(TAG, "Tests failed: %d", this->tests_failed_); - - if (this->tests_failed_ == 0) { - ESP_LOGI(TAG, "SUCCESS: All string lifetime tests passed!"); - } else { - ESP_LOGE(TAG, "FAILURE: %d string lifetime tests failed!", this->tests_failed_); - } - ESP_LOGI(TAG, "String lifetime tests complete"); -} - -void SchedulerStringLifetimeComponent::test_temporary_string_lifetime() { - ESP_LOGI(TAG, "Test 1: Temporary string lifetime for timeout names"); - - // Test with a temporary string that goes out of scope immediately - { - std::string temp_name = "temp_callback_" + std::to_string(12345); - - // Schedule with temporary string name - scheduler must copy/store this - this->set_timeout(temp_name, 1, [this]() { - ESP_LOGD(TAG, "Callback for temp string name executed"); - this->tests_passed_++; - }); - - // String goes out of scope here, but scheduler should have made a copy - } - - // Test with rvalue string as name - this->set_timeout(std::string("rvalue_test"), 2, [this]() { - ESP_LOGD(TAG, "Rvalue string name callback executed"); - this->tests_passed_++; - }); - - // Test cancelling with reconstructed string - { - std::string cancel_name = "cancel_test_" + std::to_string(999); - this->set_timeout(cancel_name, 100, [this]() { - ESP_LOGE(TAG, "This should have been cancelled!"); - this->tests_failed_++; - }); - } // cancel_name goes out of scope - - // Reconstruct the same string to cancel - std::string cancel_name_2 = "cancel_test_" + std::to_string(999); - bool cancelled = this->cancel_timeout(cancel_name_2); - if (cancelled) { - ESP_LOGD(TAG, "Successfully cancelled with reconstructed string"); - this->tests_passed_++; - } else { - ESP_LOGE(TAG, "Failed to cancel with reconstructed string"); - this->tests_failed_++; - } -} - -void SchedulerStringLifetimeComponent::test_scope_exit_string() { - ESP_LOGI(TAG, "Test 2: Scope exit string names"); - - // Create string names in a limited scope - { - std::string scoped_name = "scoped_timeout_" + std::to_string(555); - - // Schedule with scoped string name - this->set_timeout(scoped_name, 3, [this]() { - ESP_LOGD(TAG, "Scoped name callback executed"); - this->tests_passed_++; - }); - - // scoped_name goes out of scope here - } - - // Test with dynamically allocated string name - { - auto *dynamic_name = new std::string("dynamic_timeout_" + std::to_string(777)); - - this->set_timeout(*dynamic_name, 4, [this, dynamic_name]() { - ESP_LOGD(TAG, "Dynamic string name callback executed"); - this->tests_passed_++; - delete dynamic_name; // Clean up in callback - }); - - // Pointer goes out of scope but string object remains until callback - } - - // Test multiple timeouts with same dynamically created name - for (int i = 0; i < 3; i++) { - std::string loop_name = "loop_timeout_" + std::to_string(i); - this->set_timeout(loop_name, 5 + i * 1, [this, i]() { - ESP_LOGD(TAG, "Loop timeout %d executed", i); - this->tests_passed_++; - }); - // loop_name destroyed and recreated each iteration - } -} - -void SchedulerStringLifetimeComponent::test_vector_reallocation() { - ESP_LOGI(TAG, "Test 3: Vector reallocation stress on timeout names"); - - // Create a vector that will reallocate - std::vector names; - names.reserve(2); // Small initial capacity to force reallocation - - // Schedule callbacks with string names from vector - for (int i = 0; i < 10; i++) { - names.push_back("vector_cb_" + std::to_string(i)); - // Use the string from vector as timeout name - this->set_timeout(names.back(), 8 + i * 1, [this, i]() { - ESP_LOGV(TAG, "Vector name callback %d executed", i); - this->tests_passed_++; - }); - } - - // Force reallocation by adding more elements - // This will move all strings to new memory locations - for (int i = 10; i < 50; i++) { - names.push_back("realloc_trigger_" + std::to_string(i)); - } - - // Add more timeouts after reallocation to ensure old names still work - for (int i = 50; i < 55; i++) { - names.push_back("post_realloc_" + std::to_string(i)); - this->set_timeout(names.back(), 20 + (i - 50), [this]() { - ESP_LOGV(TAG, "Post-reallocation callback executed"); - this->tests_passed_++; - }); - } - - // Clear the vector while timeouts are still pending - names.clear(); - ESP_LOGD(TAG, "Vector cleared - all string names destroyed"); -} - -void SchedulerStringLifetimeComponent::test_string_move_semantics() { - ESP_LOGI(TAG, "Test 4: String move semantics for timeout names"); - - // Test moving string names - std::string original = "move_test_original"; - std::string moved = std::move(original); - - // Schedule with moved string as name - this->set_timeout(moved, 30, [this]() { - ESP_LOGD(TAG, "Moved string name callback executed"); - this->tests_passed_++; - }); - - // original is now empty, try to use it as a different timeout name - original = "reused_after_move"; - this->set_timeout(original, 32, [this]() { - ESP_LOGD(TAG, "Reused string name callback executed"); - this->tests_passed_++; - }); -} - -void SchedulerStringLifetimeComponent::test_lambda_capture_lifetime() { - ESP_LOGI(TAG, "Test 5: Complex timeout name scenarios"); - - // Test scheduling with name built in lambda - [this]() { - std::string lambda_name = "lambda_built_name_" + std::to_string(888); - this->set_timeout(lambda_name, 38, [this]() { - ESP_LOGD(TAG, "Lambda-built name callback executed"); - this->tests_passed_++; - }); - }(); // Lambda executes and lambda_name is destroyed - - // Test with shared_ptr name - auto shared_name = std::make_shared("shared_ptr_timeout"); - this->set_timeout(*shared_name, 40, [this, shared_name]() { - ESP_LOGD(TAG, "Shared_ptr name callback executed"); - this->tests_passed_++; - }); - shared_name.reset(); // Release the shared_ptr - - // Test overwriting timeout with same name - std::string overwrite_name = "overwrite_test"; - this->set_timeout(overwrite_name, 1000, [this]() { - ESP_LOGE(TAG, "This should have been overwritten!"); - this->tests_failed_++; - }); - - // Overwrite with shorter timeout - this->set_timeout(overwrite_name, 42, [this]() { - ESP_LOGD(TAG, "Overwritten timeout executed"); - this->tests_passed_++; - }); - - // Test very long string name - std::string long_name; - for (int i = 0; i < 100; i++) { - long_name += "very_long_timeout_name_segment_" + std::to_string(i) + "_"; - } - this->set_timeout(long_name, 44, [this]() { - ESP_LOGD(TAG, "Very long name timeout executed"); - this->tests_passed_++; - }); - - // Test empty string as name - this->set_timeout("", 46, [this]() { - ESP_LOGD(TAG, "Empty string name timeout executed"); - this->tests_passed_++; - }); -} - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h b/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h deleted file mode 100644 index 20185f128d..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_lifetime_component/string_lifetime_component.h +++ /dev/null @@ -1,35 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include -#include - -namespace esphome::scheduler_string_lifetime_component { - -class SchedulerStringLifetimeComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_lifetime_test(); - - // Individual test methods exposed as services - void run_test1(); - void run_test2(); - void run_test3(); - void run_test4(); - void run_test5(); - void run_final_check(); - - private: - void test_temporary_string_lifetime(); - void test_scope_exit_string(); - void test_vector_reallocation(); - void test_string_move_semantics(); - void test_lambda_capture_lifetime(); - - int tests_passed_{0}; - int tests_failed_{0}; -}; - -} // namespace esphome::scheduler_string_lifetime_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py deleted file mode 100644 index 6cc564395c..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -import esphome.codegen as cg -import esphome.config_validation as cv -from esphome.const import CONF_ID - -scheduler_string_name_stress_component_ns = cg.esphome_ns.namespace( - "scheduler_string_name_stress_component" -) -SchedulerStringNameStressComponent = scheduler_string_name_stress_component_ns.class_( - "SchedulerStringNameStressComponent", cg.Component -) - -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(SchedulerStringNameStressComponent), - } -).extend(cv.COMPONENT_SCHEMA) - - -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp deleted file mode 100644 index 677d371f25..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.cpp +++ /dev/null @@ -1,108 +0,0 @@ -#include "string_name_stress_component.h" -#include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include - -namespace esphome::scheduler_string_name_stress_component { - -static const char *const TAG = "scheduler_string_name_stress"; - -void SchedulerStringNameStressComponent::setup() { ESP_LOGCONFIG(TAG, "SchedulerStringNameStressComponent setup"); } - -void SchedulerStringNameStressComponent::run_string_name_stress_test() { - // Use member variables to reset state - this->total_callbacks_ = 0; - this->executed_callbacks_ = 0; - static constexpr int NUM_THREADS = 10; - static constexpr int CALLBACKS_PER_THREAD = 100; - - ESP_LOGI(TAG, "Starting string name stress test - multi-threaded set_timeout with std::string names"); - ESP_LOGI(TAG, "This test specifically uses dynamic string names to test memory management"); - - // Track start time - auto start_time = std::chrono::steady_clock::now(); - - // Create threads - std::vector threads; - - ESP_LOGI(TAG, "Creating %d threads, each will schedule %d callbacks with dynamic names", NUM_THREADS, - CALLBACKS_PER_THREAD); - - threads.reserve(NUM_THREADS); - for (int i = 0; i < NUM_THREADS; i++) { - threads.emplace_back([this, i]() { - ESP_LOGV(TAG, "Thread %d starting", i); - - // Each thread schedules callbacks with dynamically created string names - for (int j = 0; j < CALLBACKS_PER_THREAD; j++) { - int callback_id = this->total_callbacks_.fetch_add(1); - - // Create a dynamic string name - this will test memory management - std::stringstream ss; - ss << "thread_" << i << "_callback_" << j << "_id_" << callback_id; - std::string dynamic_name = ss.str(); - - ESP_LOGV(TAG, "Thread %d scheduling timeout with dynamic name: %s", i, dynamic_name.c_str()); - - // Capture necessary values for the lambda - auto *component = this; - - // Schedule with std::string name - this tests the string overload - // Use varying delays to stress the heap scheduler - uint32_t delay = 1 + (callback_id % 50); - - // Also test nested scheduling from callbacks - if (j % 10 == 0) { - // Every 10th callback schedules another callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d (nested scheduler)", callback_id); - - // Schedule another timeout from within this callback with a new dynamic name - std::string nested_name = "nested_from_" + std::to_string(callback_id); - component->set_timeout(nested_name, 1, [callback_id]() { - ESP_LOGV(TAG, "Executed nested string-named callback from %d", callback_id); - }); - }); - } else { - // Regular callback - this->set_timeout(dynamic_name, delay, [component, callback_id]() { - component->executed_callbacks_.fetch_add(1); - ESP_LOGV(TAG, "Executed string-named callback %d", callback_id); - }); - } - - // Add some timing variations to increase race conditions - if (j % 5 == 0) { - std::this_thread::sleep_for(std::chrono::microseconds(100)); - } - } - ESP_LOGV(TAG, "Thread %d finished scheduling", i); - }); - } - - // Wait for all threads to complete scheduling - for (auto &t : threads) { - t.join(); - } - - auto end_time = std::chrono::steady_clock::now(); - auto thread_time = std::chrono::duration_cast(end_time - start_time).count(); - ESP_LOGI(TAG, "All threads finished scheduling in %lldms. Created %d callbacks with dynamic names", thread_time, - this->total_callbacks_.load()); - - // Give some time for callbacks to execute - ESP_LOGI(TAG, "Waiting for callbacks to execute..."); - - // Schedule a final callback to signal completion - this->set_timeout("test_complete", 2000, [this]() { - ESP_LOGI(TAG, "String name stress test complete. Executed %d of %d callbacks", this->executed_callbacks_.load(), - this->total_callbacks_.load()); - }); -} - -} // namespace esphome::scheduler_string_name_stress_component diff --git a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h b/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h deleted file mode 100644 index 121bda6204..0000000000 --- a/tests/integration/fixtures/external_components/scheduler_string_name_stress_component/string_name_stress_component.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include - -namespace esphome::scheduler_string_name_stress_component { - -class SchedulerStringNameStressComponent : public Component { - public: - void setup() override; - float get_setup_priority() const override { return setup_priority::LATE; } - - void run_string_name_stress_test(); - - private: - std::atomic total_callbacks_{0}; - std::atomic executed_callbacks_{0}; -}; - -} // namespace esphome::scheduler_string_name_stress_component 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_pool.yaml b/tests/integration/fixtures/scheduler_pool.yaml index 989c1535b0..a75d9dbcbc 100644 --- a/tests/integration/fixtures/scheduler_pool.yaml +++ b/tests/integration/fixtures/scheduler_pool.yaml @@ -156,9 +156,9 @@ script: // Simulate a burst of defer operations like ratgdo does with state updates // These should execute immediately and recycle quickly to the pool + // Phase-specific id range (0..9) so ids never collide with later phases for (int i = 0; i < 10; i++) { - std::string defer_name = "defer_" + std::to_string(i); - App.scheduler.set_timeout(component, defer_name, 0, [i]() { + App.scheduler.set_timeout(component, static_cast(i), 0, [i]() { ESP_LOGD("test", "Defer %d executed", i); // Force a small delay between defer executions to see recycling if (i == 5) { @@ -207,9 +207,9 @@ script: // Now create 8 new timeouts - they should reuse from pool when available int reuse_test_count = 8; + // Phase-specific id range (100..107) so ids never collide with other phases for (int i = 0; i < reuse_test_count; i++) { - std::string name = "reuse_test_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(100 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Reuse test %d completed", i); }); } @@ -229,9 +229,9 @@ script: auto *component = id(test_sensor); int full_reuse_count = 10; + // Phase-specific id range (200..209) so ids never collide with other phases for (int i = 0; i < full_reuse_count; i++) { - std::string name = "full_reuse_" + std::to_string(i); - App.scheduler.set_timeout(component, name, 10 + i * 5, [i]() { + App.scheduler.set_timeout(component, static_cast(200 + i), 10 + i * 5, [i]() { ESP_LOGD("test", "Full reuse test %d completed", i); }); } diff --git a/tests/integration/fixtures/scheduler_string_lifetime.yaml b/tests/integration/fixtures/scheduler_string_lifetime.yaml deleted file mode 100644 index 5ae5a1914e..0000000000 --- a/tests/integration/fixtures/scheduler_string_lifetime.yaml +++ /dev/null @@ -1,48 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: scheduler-string-lifetime-test - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_lifetime_component] - -host: - -logger: - level: DEBUG - -scheduler_string_lifetime_component: - id: string_lifetime - -api: - services: - - service: run_string_lifetime_test - then: - - lambda: |- - id(string_lifetime)->run_string_lifetime_test(); - - service: run_test1 - then: - - lambda: |- - id(string_lifetime)->run_test1(); - - service: run_test2 - then: - - lambda: |- - id(string_lifetime)->run_test2(); - - service: run_test3 - then: - - lambda: |- - id(string_lifetime)->run_test3(); - - service: run_test4 - then: - - lambda: |- - id(string_lifetime)->run_test4(); - - service: run_test5 - then: - - lambda: |- - id(string_lifetime)->run_test5(); - - service: run_final_check - then: - - lambda: |- - id(string_lifetime)->run_final_check(); diff --git a/tests/integration/fixtures/scheduler_string_name_stress.yaml b/tests/integration/fixtures/scheduler_string_name_stress.yaml deleted file mode 100644 index 8f68d1d102..0000000000 --- a/tests/integration/fixtures/scheduler_string_name_stress.yaml +++ /dev/null @@ -1,39 +0,0 @@ -esphome: - debug_scheduler: true # Enable scheduler leak detection - name: sched-string-name-stress - -external_components: - - source: - type: local - path: EXTERNAL_COMPONENT_PATH - components: [scheduler_string_name_stress_component] - -host: - -logger: - level: VERBOSE - -scheduler_string_name_stress_component: - id: string_stress - -api: - services: - - service: run_string_name_stress_test - then: - - lambda: |- - id(string_stress)->run_string_name_stress_test(); - -event: - - platform: template - name: "Test Complete" - id: test_complete - device_class: button - event_types: - - "test_finished" - - platform: template - name: "Test Result" - id: test_result - device_class: button - event_types: - - "passed" - - "failed" diff --git a/tests/integration/fixtures/scheduler_string_test.yaml b/tests/integration/fixtures/scheduler_string_test.yaml index c53ec392df..06e3a4c97c 100644 --- a/tests/integration/fixtures/scheduler_string_test.yaml +++ b/tests/integration/fixtures/scheduler_string_test.yaml @@ -18,9 +18,6 @@ globals: - id: interval_counter type: int initial_value: '0' - - id: dynamic_counter - type: int - initial_value: '0' - id: static_tests_done type: bool initial_value: 'false' @@ -103,46 +100,43 @@ script: - id: test_dynamic_strings then: - - logger.log: "Testing dynamic string timeouts and intervals" + - logger.log: "Testing const char* timeouts and intervals" - lambda: |- auto *component2 = id(test_sensor2); - // Test 8: Dynamic string with set_timeout (std::string) - std::string dynamic_name = "dynamic_timeout_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_timeout(component2, dynamic_name, 100, []() { + // Test 8: const char* name with set_timeout + App.scheduler.set_timeout(component2, "dynamic_timeout", 100, []() { ESP_LOGI("test", "Dynamic timeout fired"); id(timeout_counter) += 1; }); - // Test 9: Dynamic string with set_interval - std::string interval_name = "dynamic_interval_" + std::to_string(id(dynamic_counter)++); - App.scheduler.set_interval(component2, interval_name, 250, [interval_name]() { - ESP_LOGI("test", "Dynamic interval fired: %s", interval_name.c_str()); + // Test 9: const char* name with set_interval, cancelled from inside the callback + App.scheduler.set_interval(component2, "dynamic_interval", 250, []() { + ESP_LOGI("test", "Dynamic interval fired"); id(interval_counter) += 1; if (id(interval_counter) >= 6) { - App.scheduler.cancel_interval(id(test_sensor2), interval_name); + App.scheduler.cancel_interval(id(test_sensor2), "dynamic_interval"); ESP_LOGI("test", "Cancelled dynamic interval"); } }); - // Test 10: Cancel with different string object but same content - std::string cancel_name = "cancel_test"; - App.scheduler.set_timeout(component2, cancel_name, 2000, []() { + // Test 10: Cancel with a different pointer but identical content. + // STATIC_STRING names match by content, so a distinct static buffer with the + // same characters still cancels the scheduled timeout. + static const char CANCEL_NAME[] = "cancel_test"; + App.scheduler.set_timeout(component2, CANCEL_NAME, 2000, []() { ESP_LOGI("test", "This should be cancelled"); }); + static const char CANCEL_NAME_2[] = "cancel_test"; + App.scheduler.cancel_timeout(component2, CANCEL_NAME_2); + ESP_LOGI("test", "Cancelled timeout using different buffer with same content"); - // Cancel using a different string object - std::string cancel_name_2 = "cancel_test"; - App.scheduler.cancel_timeout(component2, cancel_name_2); - ESP_LOGI("test", "Cancelled timeout using different string object"); - - // Test 11: Dynamic string with defer (using std::string overload) + // Test 11: const char* name with defer class TestDynamicDeferComponent : public Component { public: void test_dynamic_defer() { - std::string defer_name = "dynamic_defer_" + std::to_string(id(dynamic_counter)++); - this->defer(defer_name, [defer_name]() { - ESP_LOGI("test", "Dynamic defer fired: %s", defer_name.c_str()); + this->defer("dynamic_defer", []() { + ESP_LOGI("test", "Dynamic defer fired"); id(timeout_counter) += 1; }); } 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/fixtures/use_address_runtime.yaml b/tests/integration/fixtures/use_address_runtime.yaml new file mode 100644 index 0000000000..29f3369285 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime.yaml @@ -0,0 +1,8 @@ +esphome: + name: use-address-runtime + +host: + +api: + +logger: diff --git a/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml new file mode 100644 index 0000000000..9785724cd5 --- /dev/null +++ b/tests/integration/fixtures/use_address_runtime_mac_suffix.yaml @@ -0,0 +1,9 @@ +esphome: + name: use-address-mac + name_add_mac_suffix: true + +host: + +api: + +logger: diff --git a/tests/integration/fixtures/wait_until_reentrant_restart.yaml b/tests/integration/fixtures/wait_until_reentrant_restart.yaml new file mode 100644 index 0000000000..337d0de837 --- /dev/null +++ b/tests/integration/fixtures/wait_until_reentrant_restart.yaml @@ -0,0 +1,86 @@ +esphome: + name: wait-until-reentrant-restart + +host: + +api: + actions: + - action: start_self_restart + then: + - script.execute: retry_script + - action: start_stop_during_wait + then: + - globals.set: + id: gate_open + value: 'false' + # num 0 is a blocker: its condition never becomes true, so it is still + # waiting (already checked and set aside) when num 1 stops the script - + # it must be cancelled, not restored, so its timeout must never fire + - script.execute: + id: waiter + num: 0 + - script.execute: + id: waiter + num: 1 + - script.execute: + id: waiter + num: 2 + - script.execute: + id: waiter + num: 3 + # Give all three instances time to queue in the same wait_until + - delay: 100ms + - globals.set: + id: gate_open + value: 'true' + - delay: 200ms + - logger.log: "stop test complete" + +logger: + level: DEBUG + +globals: + - id: attempt + type: int + initial_value: '0' + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Self-restart retry pattern: when the wait_until times out, the rest of the + # script runs synchronously from inside the wait queue processing and restarts + # this same script - re-entering the same WaitUntilAction while it is still + # processing its queue. This used to corrupt the queue and crash. + - id: retry_script + mode: restart + then: + - wait_until: + condition: + lambda: 'return false;' + timeout: 20ms + - lambda: |- + id(attempt) += 1; + ESP_LOGD("test", "attempt %d done", id(attempt)); + - if: + condition: + lambda: 'return id(attempt) < 5;' + then: + - script.execute: retry_script + else: + - logger.log: "retry test complete" + + # Parallel waiters all queued in the same wait_until; the first one to pass the + # gate stops the script from its continuation, cancelling the other waiters + # while the queue is still being processed. + - id: waiter + mode: parallel + parameters: + num: int + then: + - wait_until: + condition: + lambda: 'return num != 0 && id(gate_open);' + timeout: 1s + - lambda: 'ESP_LOGD("test", "gate passed %d", num);' + - script.stop: waiter diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index c8517aff09..65af57b944 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -19,7 +19,6 @@ from aioesphomeapi import ( _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=EntityInfo) S = TypeVar("S", bound=EntityState) @@ -58,7 +57,7 @@ async def wait_for_state( return await asyncio.wait_for(future, timeout=timeout) -def find_entity( +def find_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, @@ -86,7 +85,7 @@ def find_entity( return None -def require_entity( +def require_entity[T: EntityInfo]( entities: list[EntityInfo], object_id_substring: str, entity_type: type[T] | None = None, diff --git a/tests/integration/test_api_homeassistant_action_no_subscriber.py b/tests/integration/test_api_homeassistant_action_no_subscriber.py new file mode 100644 index 0000000000..9e7594e0ce --- /dev/null +++ b/tests/integration/test_api_homeassistant_action_no_subscriber.py @@ -0,0 +1,79 @@ +"""Integration test for Home Assistant actions fired without a subscriber. + +Home Assistant subscribes to device actions shortly after authenticating, while +on_client_connected (and similar triggers) fire right at authentication. Actions +fired before any client has subscribed cannot be delivered - they must produce a +warning in the log instead of vanishing silently. +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, HomeassistantServiceCall +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_homeassistant_action_no_subscriber( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Undeliverable actions warn in the log; actions after subscribing arrive.""" + loop = asyncio.get_running_loop() + + boot_warning_future = loop.create_future() + connected_warning_future = loop.create_future() + button_action_future = loop.create_future() + + def check_output(line: str) -> None: + if ( + not boot_warning_future.done() + and "Home Assistant action 'test.boot_action' dropped; no client connected" + in line + ): + boot_warning_future.set_result(True) + if ( + not connected_warning_future.done() + and "Home Assistant action 'test.connected_action' dropped; " + "client has not subscribed to actions (yet)" + in line + ): + connected_warning_future.set_result(True) + + service_calls: list[HomeassistantServiceCall] = [] + + def on_service_call(service_call: HomeassistantServiceCall) -> None: + service_calls.append(service_call) + if ( + service_call.service == "test.button_action" + and not button_action_future.done() + ): + button_action_future.set_result(service_call) + + async with run_compiled(yaml_config, line_callback=check_output): + # The on_boot action fires with no client connected at all. + await asyncio.wait_for(boot_warning_future, timeout=10.0) + + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "test-ha-action-no-subscriber" + + # on_client_connected fired at authentication, before this client + # subscribed to Home Assistant actions. + await asyncio.wait_for(connected_warning_future, timeout=5.0) + + # After subscribing, actions must be delivered normally (and the + # dropped ones must not suddenly show up). + client.subscribe_service_calls(on_service_call) + + entities, _ = await client.list_entities_services() + button = next(e for e in entities if isinstance(e, ButtonInfo)) + client.button_command(button.key) + + button_call = await asyncio.wait_for(button_action_future, timeout=5.0) + assert button_call.data == {"value": "subscribed"} + assert [call.service for call in service_calls] == ["test.button_action"] diff --git a/tests/integration/test_api_zero_psk_provisioning.py b/tests/integration/test_api_zero_psk_provisioning.py new file mode 100644 index 0000000000..bcea2a2471 --- /dev/null +++ b/tests/integration/test_api_zero_psk_provisioning.py @@ -0,0 +1,127 @@ +"""Integration tests for provisioning the encryption key over a zero-PSK connection. + +A device with `api: encryption:` but no key accepts Noise handshakes using the +well-known all-zeros PSK. The ephemeral X25519 exchange protects the key from +passive sniffing while it is provisioned; plaintext provisioning still works +but is deprecated. +""" + +from __future__ import annotations + +import asyncio +import base64 + +from aioesphomeapi import InvalidEncryptionKeyAPIError, RequiresEncryptionAPIError +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The well-known provisioning PSK: base64 of 32 zero bytes +ZERO_PSK = base64.b64encode(bytes(32)).decode() +# A real key to provision +NEW_KEY = base64.b64encode(b"n" * 32) +# Time for the device to activate a newly saved key (100ms timer plus margin) +KEY_ACTIVATION_DELAY = 0.5 + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so every run starts unprovisioned.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Exercise the reject paths, then provision a key over the zero-PSK channel.""" + async with run_compiled(yaml_config): + # --- Pre-provisioning reject paths (device state is unchanged) --- + + # A wrong (non-zero) PSK fails against the zero provisioning PSK + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected( + noise_psk=base64.b64encode(b"w" * 32).decode(), timeout=5 + ) as client: + await client.device_info() + + # A plaintext client and a zero-PSK client can be connected at the + # same time while the device is unprovisioned + async with ( + api_client_connected() as plaintext_client, + api_client_connected(noise_psk=ZERO_PSK) as noise_client, + ): + plaintext_info = await plaintext_client.device_info() + noise_info = await noise_client.device_info() + # Both transports advertise provisioning support so old and new + # clients can decide how to provision + assert plaintext_info.api_encryption_provisionable is True + assert noise_info.api_encryption_provisionable is True + + # The all-zeros key is reserved as the provisioning PSK and is + # rejected on both transports + zero_key = base64.b64encode(bytes(32)) + assert await noise_client.noise_encryption_set_key(zero_key) is False + assert await plaintext_client.noise_encryption_set_key(zero_key) is False + + # --- Provision over the zero-PSK channel --- + + # The unprovisioned device accepts the all-zeros PSK; the handshake's + # ephemeral-ephemeral DH encrypts everything that follows + async with api_client_connected(noise_psk=ZERO_PSK) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_supported is True + assert device_info.api_encryption_provisionable is True + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + # The device activates the new key shortly after responding + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The new key now works, and the device is no longer provisionable + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-provision-test" + assert device_info.api_encryption_provisionable is False + + # The zero PSK no longer works + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() + + # Plaintext no longer works + with pytest.raises(RequiresEncryptionAPIError): + async with api_client_connected(timeout=5) as client: + await client.device_info() + + +@pytest.mark.asyncio +async def test_api_zero_psk_provisioning_plaintext( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The legacy plaintext provisioning path still works and warns.""" + log_lines: list[str] = [] + async with run_compiled(yaml_config, line_callback=log_lines.append): + async with api_client_connected() as client: + device_info = await client.device_info() + assert device_info.name == "zero-psk-plaintext-test" + + assert await client.noise_encryption_set_key(NEW_KEY) is True + + await asyncio.sleep(KEY_ACTIVATION_DELAY) + + # The deprecation warning was logged + assert any("deprecated" in line for line in log_lines) + + # The new key works; the zero PSK does not + async with api_client_connected(noise_psk=NEW_KEY.decode()) as client: + assert (await client.device_info()).name == "zero-psk-plaintext-test" + + with pytest.raises(InvalidEncryptionKeyAPIError): + async with api_client_connected(noise_psk=ZERO_PSK, timeout=5) as client: + await client.device_info() diff --git a/tests/integration/test_light_calls.py b/tests/integration/test_light_calls.py index 0eaf5af91b..a3a4103f5c 100644 --- a/tests/integration/test_light_calls.py +++ b/tests/integration/test_light_calls.py @@ -322,6 +322,49 @@ async def test_light_calls( assert state.state is True assert state.brightness == pytest.approx(0.75) + # Test 31: Setting brightness to 0 without an explicit state implicitly turns + # the light off; turning it back on (without an explicit brightness) then + # restores full brightness so the light is visible again. + client.light_command(key=rgbcw_light.key, state=True, brightness=0.5) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.5) + + # Brightness 0 with no explicit state -> implicit turn-off + client.light_command(key=rgbcw_light.key, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + assert state.brightness == pytest.approx(0.0) + # Turning on without an explicit brightness restores it to full brightness + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 31b: An explicit turn-on with brightness 0 still resets to full + # brightness - a turn-on must never leave the light on-but-invisible. This + # is the same path the restore logic exercises (set_state(true) + + # set_brightness(0) from a persisted brightness=0 turn-off). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.0) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(1.0) + + # Test 32: Turning a light on when it already has nonzero brightness leaves + # the brightness unchanged (the reset only happens when brightness is 0). + client.light_command(key=rgbcw_light.key, state=True, brightness=0.4) + state = await wait_for_state_change(rgbcw_light.key) + assert state.brightness == pytest.approx(0.4) + + client.light_command(key=rgbcw_light.key, state=False) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is False + + client.light_command(key=rgbcw_light.key, state=True) + state = await wait_for_state_change(rgbcw_light.key) + assert state.state is True + assert state.brightness == pytest.approx(0.4) + # Final cleanup - turn all lights off for light in lights: client.light_command( diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py deleted file mode 100644 index bfa581129b..0000000000 --- a/tests/integration/test_scheduler_string_lifetime.py +++ /dev/null @@ -1,169 +0,0 @@ -"""String lifetime test - verify scheduler handles string destruction correctly.""" - -import asyncio -from pathlib import Path -import re - -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_lifetime( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that scheduler correctly handles string lifetimes when strings go out of scope.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create events for synchronization - test1_complete = asyncio.Event() - test2_complete = asyncio.Event() - test3_complete = asyncio.Event() - test4_complete = asyncio.Event() - test5_complete = asyncio.Event() - all_tests_complete = asyncio.Event() - - # Track test progress - test_stats = { - "tests_passed": 0, - "tests_failed": 0, - "errors": [], - "current_test": None, - "test_callbacks_executed": {}, - } - - def on_log_line(line: str) -> None: - # Track test-specific events - if "Test 1 complete" in line: - test1_complete.set() - elif "Test 2 complete" in line: - test2_complete.set() - elif "Test 3 complete" in line: - test3_complete.set() - elif "Test 4 complete" in line: - test4_complete.set() - elif "Test 5 complete" in line: - test5_complete.set() - - # Track individual callback executions - callback_match = re.search(r"Callback '(.+?)' executed", line) - if callback_match: - callback_name = callback_match.group(1) - test_stats["test_callbacks_executed"][callback_name] = True - - # Track test results from the C++ test output - if "Tests passed:" in line and "string_lifetime" in line: - # Extract the number from "Tests passed: 32" - match = re.search(r"Tests passed:\s*(\d+)", line) - if match: - test_stats["tests_passed"] = int(match.group(1)) - elif "Tests failed:" in line and "string_lifetime" in line: - match = re.search(r"Tests failed:\s*(\d+)", line) - if match: - test_stats["tests_failed"] = int(match.group(1)) - elif "ERROR" in line and "string_lifetime" in line: - test_stats["errors"].append(line) - - # Check for memory corruption indicators - if any( - indicator in line.lower() - for indicator in [ - "use after free", - "heap corruption", - "segfault", - "abort", - "assertion", - "sanitizer", - "bad memory", - "invalid pointer", - ] - ): - pytest.fail(f"Memory corruption detected: {line}") - - # Check for completion - if "String lifetime tests complete" in line: - all_tests_complete.set() - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "scheduler-string-lifetime-test" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test services - test_services = {} - for service in services: - if service.name == "run_test1": - test_services["test1"] = service - elif service.name == "run_test2": - test_services["test2"] = service - elif service.name == "run_test3": - test_services["test3"] = service - elif service.name == "run_test4": - test_services["test4"] = service - elif service.name == "run_test5": - test_services["test5"] = service - elif service.name == "run_final_check": - test_services["final"] = service - - # Ensure all services are found - required_services = ["test1", "test2", "test3", "test4", "test5", "final"] - for service_name in required_services: - assert service_name in test_services, f"{service_name} service not found" - - # Run tests sequentially, waiting for each to complete - try: - # Test 1 - await client.execute_service(test_services["test1"], {}) - await asyncio.wait_for(test1_complete.wait(), timeout=5.0) - - # Test 2 - await client.execute_service(test_services["test2"], {}) - await asyncio.wait_for(test2_complete.wait(), timeout=5.0) - - # Test 3 - await client.execute_service(test_services["test3"], {}) - await asyncio.wait_for(test3_complete.wait(), timeout=5.0) - - # Test 4 - await client.execute_service(test_services["test4"], {}) - await asyncio.wait_for(test4_complete.wait(), timeout=5.0) - - # Test 5 - await client.execute_service(test_services["test5"], {}) - await asyncio.wait_for(test5_complete.wait(), timeout=5.0) - - # Final check - await client.execute_service(test_services["final"], {}) - await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) - - except TimeoutError: - pytest.fail(f"String lifetime test timed out. Stats: {test_stats}") - - # Check for any errors - assert test_stats["tests_failed"] == 0, f"Tests failed: {test_stats['errors']}" - - # Verify we had the expected number of passing tests - assert test_stats["tests_passed"] == 30, ( - f"Expected exactly 30 tests to pass, but got {test_stats['tests_passed']}" - ) diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py deleted file mode 100644 index 56b8998c56..0000000000 --- a/tests/integration/test_scheduler_string_name_stress.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Stress test for heap scheduler with std::string names from multiple threads.""" - -import asyncio -from pathlib import Path -import re - -from aioesphomeapi import UserService -import pytest - -from .types import APIClientConnectedFactory, RunCompiledFunction - - -@pytest.mark.asyncio -async def test_scheduler_string_name_stress( - yaml_config: str, - run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, -) -> None: - """Test that set_timeout/set_interval with std::string names doesn't crash when called from multiple threads.""" - - # Get the absolute path to the external components directory - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - - # Replace the placeholder in the YAML config with the actual path - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - # Create a future to signal test completion - loop = asyncio.get_running_loop() - test_complete_future: asyncio.Future[None] = loop.create_future() - - # Track executed callbacks and any crashes - executed_callbacks: set[int] = set() - error_messages: list[str] = [] - - def on_log_line(line: str) -> None: - # Check for crash indicators - if any( - indicator in line.lower() - for indicator in [ - "segfault", - "abort", - "assertion", - "heap corruption", - "use after free", - ] - ): - error_messages.append(line) - if not test_complete_future.done(): - test_complete_future.set_exception(Exception(f"Crash detected: {line}")) - return - - # Track executed callbacks - match = re.search(r"Executed string-named callback (\d+)", line) - if match: - callback_id = int(match.group(1)) - executed_callbacks.add(callback_id) - - # Check for completion - if ( - "String name stress test complete" in line - and not test_complete_future.done() - ): - test_complete_future.set_result(None) - - async with ( - run_compiled(yaml_config, line_callback=on_log_line), - api_client_connected() as client, - ): - # Verify we can connect - device_info = await client.device_info() - assert device_info is not None - assert device_info.name == "sched-string-name-stress" - - # List entities and services - _, services = await asyncio.wait_for( - client.list_entities_services(), timeout=5.0 - ) - - # Find our test service - run_stress_test_service: UserService | None = None - for service in services: - if service.name == "run_string_name_stress_test": - run_stress_test_service = service - break - - assert run_stress_test_service is not None, ( - "run_string_name_stress_test service not found" - ) - - # Call the service to start the test - await client.execute_service(run_stress_test_service, {}) - - # Wait for test to complete or crash - try: - await asyncio.wait_for(test_complete_future, timeout=30.0) - except TimeoutError: - pytest.fail( - f"String name stress test timed out. Executed {len(executed_callbacks)} callbacks. " - f"This might indicate a deadlock." - ) - - # Verify no errors occurred (crashes already handled by exception) - assert not error_messages, f"Errors detected during test: {error_messages}" - - # Verify we executed all 1000 callbacks (10 threads × 100 callbacks each) - assert len(executed_callbacks) == 1000, ( - f"Expected 1000 callbacks but got {len(executed_callbacks)}" - ) - - # Verify each callback ID was executed exactly once - for i in range(1000): - assert i in executed_callbacks, f"Callback {i} was not executed" diff --git a/tests/integration/test_scheduler_string_test.py b/tests/integration/test_scheduler_string_test.py index 783ed37c13..3bc3487432 100644 --- a/tests/integration/test_scheduler_string_test.py +++ b/tests/integration/test_scheduler_string_test.py @@ -99,7 +99,7 @@ async def test_scheduler_string_test( timeout_count += 1 # Check for cancel test - elif "Cancelled timeout using different string object" in clean_line: + elif "Cancelled timeout using different buffer with same content" in clean_line: cancel_test_done.set() # Check for final results 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/integration/test_use_address_runtime.py b/tests/integration/test_use_address_runtime.py new file mode 100644 index 0000000000..a4cbbb9c5f --- /dev/null +++ b/tests/integration/test_use_address_runtime.py @@ -0,0 +1,73 @@ +"""Integration tests for the runtime-built use_address. + +The default ".local" address is no longer stored as a compile-time string; +it is built at runtime from the device name. This also fixes the logged address +when name_add_mac_suffix is enabled: the baked string used to miss the MAC +suffix, so it never matched the actual mDNS hostname. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Host platform default MAC: 98:35:69:ab:f6:79 -> suffix "abf679" +MAC_SUFFIX = "abf679" + + +@pytest.mark.asyncio +async def test_use_address_runtime( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The API dump_config logs ".local" built from the device name.""" + address_seen = asyncio.Event() + + def check_output(line: str) -> None: + if "Address: use-address-runtime.local:" in line: + address_seen.set() + + 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 + assert device_info.name == "use-address-runtime" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail("Did not log 'Address: use-address-runtime.local:'") + + +@pytest.mark.asyncio +async def test_use_address_runtime_mac_suffix( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """With name_add_mac_suffix the logged address includes the MAC suffix.""" + address_seen = asyncio.Event() + expected = f"Address: use-address-mac-{MAC_SUFFIX}.local:" + + def check_output(line: str) -> None: + if expected in line: + address_seen.set() + + 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 + assert device_info.name == f"use-address-mac-{MAC_SUFFIX}" + + try: + await asyncio.wait_for(address_seen.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Did not log '{expected}'") diff --git a/tests/integration/test_wait_until_reentrant_restart.py b/tests/integration/test_wait_until_reentrant_restart.py new file mode 100644 index 0000000000..9c73339515 --- /dev/null +++ b/tests/integration/test_wait_until_reentrant_restart.py @@ -0,0 +1,89 @@ +"""Integration test for wait_until queue reentrancy. + +When a wait_until completes, the rest of the action chain runs synchronously +from inside the wait queue processing. That chain can re-enter the very same +WaitUntilAction - for example a script with mode: restart that executes itself +as a retry pattern, or a waiter that stops its own script. Both used to mutate +the std::list while it was being iterated, corrupting it and crashing the +device (Guru Meditation StoreProhibited in _M_transfer). +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_reentrant_restart( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that re-entering a wait_until from its own continuation is safe.""" + retry_complete = asyncio.Event() + stop_complete = asyncio.Event() + + attempt_pattern = re.compile(r"attempt (\d+) done") + gate_pattern = re.compile(r"gate passed (\d+)") + + attempts: list[int] = [] + gate_passed: list[int] = [] + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if mo := attempt_pattern.search(line): + attempts.append(int(mo.group(1))) + elif mo := gate_pattern.search(line): + gate_passed.append(int(mo.group(1))) + elif "retry test complete" in line: + retry_complete.set() + elif "stop test complete" in line: + stop_complete.set() + + 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 + assert device_info.name == "wait-until-reentrant-restart" + + _, services = await client.list_entities_services() + self_restart_service = next( + (s for s in services if s.name == "start_self_restart"), None + ) + assert self_restart_service is not None, "start_self_restart not found" + stop_service = next( + (s for s in services if s.name == "start_stop_during_wait"), None + ) + assert stop_service is not None, "start_stop_during_wait not found" + + # Scenario 1: the wait_until timeout continuation restarts its own + # script five times, re-entering the same wait_until each time. + await client.execute_service(self_restart_service, {}) + try: + await asyncio.wait_for(retry_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Self-restart retry did not finish. Attempts: {attempts}") + assert attempts == [1, 2, 3, 4, 5], attempts + + # Scenario 2: the first waiter through the gate stops the script while + # the other waiters are still queued in the same wait_until; both the + # not-yet-checked waiters (2, 3) and the already-checked still-waiting + # blocker (0) must be cancelled, not fired. + await client.execute_service(stop_service, {}) + try: + await asyncio.wait_for(stop_complete.wait(), timeout=10.0) + except TimeoutError: + pytest.fail(f"Stop-during-wait did not finish. Gate passed: {gate_passed}") + assert gate_passed == [1], gate_passed + + # If the cancelled blocker had been kept, its 1s wait_until timeout + # would still fire - give it the chance and check it stays silent. + await asyncio.sleep(1.5) + assert gate_passed == [1], gate_passed 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 a9defcacac..d018c6dbd0 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 @@ -231,14 +231,16 @@ def test_main_all_tests_should_run( assert output["memory_impact"]["should_run"] == "false" assert output["cpp_unit_tests_run_all"] is False assert output["cpp_unit_tests_components"] == ["wifi", "api", "sensor"] - # component_test_batches should be present and be a list of space-separated strings + # component_test_batches should be a list of matrix entries carrying the + # space-separated component list and the toolchain-need flags assert "component_test_batches" in output assert isinstance(output["component_test_batches"], list) - # Each batch should be a space-separated string of component names for batch in output["component_test_batches"]: - assert isinstance(batch, str) + assert isinstance(batch, dict) # Should contain at least one component (no empty batches) - assert len(batch) > 0 + assert len(batch["components"]) > 0 + assert isinstance(batch["needs_idf"], bool) + assert isinstance(batch["needs_nrf"], bool) def test_main_no_tests_should_run( @@ -248,7 +250,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 +266,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 +307,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 +324,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 +390,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 +406,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 +445,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 +459,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 @@ -503,7 +564,7 @@ def test_determine_integration_tests( with patch.object( determine_jobs, "changed_files", - return_value=["esphome/dashboard/web_server.py"], + return_value=["esphome/analyze_memory/helpers.py"], ): run_all, test_files = determine_jobs.determine_integration_tests() assert run_all is False @@ -653,52 +714,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( @@ -869,7 +916,6 @@ def test_should_run_core_ci_with_branch() -> None: # picks them up because esphome's pyproject sets # include-package-data = true. (["esphome/idf_component.yml"], True), - (["esphome/dashboard/templates/index.html"], True), (["esphome/components/api/api_pb2_service.json"], True), # Mixed: any triggering file is enough (["docs/README.md", "esphome/config.py"], True), @@ -930,23 +976,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 +1003,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 +1024,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 +1038,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 +1060,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 +1080,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 +1092,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"), [ @@ -2159,15 +2225,33 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "esphome/components/libretiny/wifi_ln882x.cpp", determine_jobs.Platform.LN882X_ARD, ), - # RP2040 / Raspberry Pi Pico detection + # RP2 family detection — explicit chip names only. + # RP2040 chip: _rp2040.*, _pico.* (Pico / Pico W) ("esphome/components/gpio/gpio_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/wifi/wifi_rp2040.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/i2c/i2c_pico.cpp", determine_jobs.Platform.RP2040_ARD), ("esphome/components/spi/spi_pico.cpp", determine_jobs.Platform.RP2040_ARD), ( - "tests/components/rp2040/test.rp2040-ard.yaml", + "tests/components/rp2/test.rp2040-ard.yaml", determine_jobs.Platform.RP2040_ARD, ), + # RP2350 chip: _rp2350.*, _pico2.* (Pico 2 / Pico 2 W) + ( + "esphome/components/foo/foo_rp2350.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "esphome/components/wifi/wifi_pico2.cpp", + determine_jobs.Platform.RP2350_ARD, + ), + ( + "tests/components/rp2/test.rp2350-ard.yaml", + determine_jobs.Platform.RP2350_ARD, + ), + # Family-wide files (_rp2.*) intentionally do NOT get a hint — + # they apply to both RP2040 and RP2350 chips. + ("esphome/components/debug/debug_rp2.cpp", None), + ("esphome/components/logger/logger_rp2.h", None), # nRF52 / Zephyr detection ( "tests/components/logger/test.nrf52-adafruit.yaml", @@ -2214,6 +2298,11 @@ def test_detect_memory_impact_config_runs_at_component_limit(tmp_path: Path) -> "pico_i2c", "pico_spi", "rp2040_test_yaml", + "rp2350_cpp", + "pico2_cpp", + "rp2350_test_yaml", + "rp2_family_debug_no_hint", + "rp2_family_logger_h_no_hint", "nrf52_test_yaml", "nrf52_gpio", "zephyr_core", @@ -2353,16 +2442,16 @@ def test_component_batching_beta_branch_40_per_batch( assert len(batches) == 3, f"Expected 3 batches, got {len(batches)}" # Each batch should have approximately 40 components (all weight=1, groupable) - for i, batch_str in enumerate(batches): - batch_components = batch_str.split() + for i, batch in enumerate(batches): + batch_components = batch["components"].split() assert len(batch_components) == 40, ( f"Batch {i} should have 40 components, got {len(batch_components)}" ) # Verify all 120 components are in batches all_components = [] - for batch_str in batches: - all_components.extend(batch_str.split()) + for batch in batches: + all_components.extend(batch["components"].split()) assert len(all_components) == 120 assert set(all_components) == set(component_names) @@ -2765,7 +2854,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], @@ -2786,7 +2875,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 = [] @@ -2827,9 +2916,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 @@ -2840,7 +2929,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 @@ -2854,7 +2943,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], @@ -2869,7 +2958,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 = [] @@ -2900,7 +2989,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_helpers.py b/tests/script/test_helpers.py index 82ff5e1411..886d413ccf 100644 --- a/tests/script/test_helpers.py +++ b/tests/script/test_helpers.py @@ -35,6 +35,8 @@ def clear_helpers_cache() -> None: helpers._get_github_event_data.cache_clear() helpers._get_changed_files_github_actions.cache_clear() helpers.get_components_per_integration_fixture.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() @pytest.mark.parametrize( @@ -1504,6 +1506,8 @@ def fake_components(tmp_path: Path) -> Path: write("callable_auto", "def AUTO_LOAD():\n return ['beta']\n") write("broken", "this is not valid python !!!") helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() return tmp_path @@ -1624,6 +1628,47 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket( assert signature.startswith("i2c__conflict") +def test_split_conflicting_groups_seeds_from_test_config( + fake_components: Path, monkeypatch: MonkeyPatch +) -> None: + """A conflict reachable only via a component's test config splits the group. + + ``host_user`` declares no static conflict with ``beta``, but its + ``test..yaml`` pulls in ``beta_variant`` (which AUTO_LOADs + ``beta``). On that platform the group must split; on another platform + (no such test config) it must stay together. + """ + monkeypatch.setattr(helpers, "root_path", str(fake_components)) + + # host_user has no static metadata, but its esp32 test config references + # beta_variant -> AUTO_LOAD beta, which conflicts with alpha. + tests_dir = fake_components / "tests" / "components" / "host_user" + tests_dir.mkdir(parents=True) + (tests_dir / "test.esp32.yaml").write_text("beta_variant:\n") + (fake_components / "esphome" / "components" / "host_user").mkdir() + ( + fake_components / "esphome" / "components" / "host_user" / "__init__.py" + ).write_text("") + + helpers.parse_component_metadata.cache_clear() + helpers._get_test_config_components.cache_clear() + helpers._conflict_walk.cache_clear() + + # On esp32, host_user pulls in beta (via its test config) -> conflicts with alpha. + result = helpers.split_conflicting_groups( + {("esp32", "no_buses"): ["alpha", "host_user"]} + ) + buckets = list(result.values()) + for bucket in buckets: + assert not ({"alpha", "host_user"} <= set(bucket)) + + # On a platform without that test config, they stay grouped together. + result_other = helpers.split_conflicting_groups( + {("rp2040", "no_buses"): ["alpha", "host_user"]} + ) + assert result_other == {("rp2040", "no_buses"): ["alpha", "host_user"]} + + # --------------------------------------------------------------------------- # get_component_test_files / is_validate_only_file # --------------------------------------------------------------------------- diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py index 6ed1bd2c1e..27be3b5628 100644 --- a/tests/script/test_merge_component_configs.py +++ b/tests/script/test_merge_component_configs.py @@ -10,7 +10,10 @@ sys.path.insert(0, str((Path(__file__).parent / ".." / ".." / "script").resolve( import merge_component_configs # noqa: E402 +from esphome import yaml_util # noqa: E402 + deduplicate_by_id = merge_component_configs.deduplicate_by_id +prepare_component_body = merge_component_configs.prepare_component_body def test_identical_duplicate_ids_collapse() -> None: @@ -99,3 +102,56 @@ def test_nested_lists_are_checked() -> None: } with pytest.raises(ValueError, match="dup"): deduplicate_by_id(data) + + +def test_nested_package_includes_are_fully_expanded(tmp_path: Path) -> None: + """A package include that itself pulls in another package expands fully. + + Mirrors web_server's tests, where test.yaml includes common_v2, which + includes common (holding the wifi/network config). Without recursive + expansion the nested include is dropped and network config is lost. + """ + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "common_v2.yaml").write_text( + "packages:\n device_base: !include common.yaml\nweb_server:\n port: 8080\n" + ) + (tmp_path / "test.yaml").write_text( + "packages:\n web_server: !include common_v2.yaml\n" + "web_server:\n auth:\n username: admin\n" + ) + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "web_server", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} + assert result["web_server"] == {"port": 8080, "auth": {"username": "admin"}} + + +def test_common_bus_package_is_left_for_caller(tmp_path: Path) -> None: + """Common bus packages are not expanded inline; the caller re-adds them.""" + comp_data = { + "packages": { + "i2c": {"sda": 21, "scl": 22}, + "device_base": {"wifi": {"ssid": "MySSID"}}, + }, + } + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + # The bus package's body must not be merged in, and the packages key is + # dropped entirely for the caller to re-add the common bus package. + assert "packages" not in result + assert "sda" not in result + assert result["wifi"] == {"ssid": "MySSID"} + + +def test_list_style_packages_are_expanded(tmp_path: Path) -> None: + """List-style package includes are expanded and the key removed.""" + (tmp_path / "common.yaml").write_text("wifi:\n ssid: MySSID\n") + (tmp_path / "test.yaml").write_text("packages:\n - !include common.yaml\n") + + comp_data = yaml_util.load_yaml(tmp_path / "test.yaml") + result = prepare_component_body(comp_data, "mycomp", tmp_path) + + assert "packages" not in result + assert result["wifi"] == {"ssid": "MySSID"} diff --git a/tests/test_build_components/build_components_base.esp32-c61-idf.yaml b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml new file mode 100644 index 0000000000..e1bd4645cc --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c61-idf.yaml @@ -0,0 +1,18 @@ +esphome: + name: componenttestesp32c61idf + friendly_name: $component_name + +esp32: + variant: ESP32C61 + flash_size: 8MB + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/build_components_base.ln882x-ard.yaml b/tests/test_build_components/build_components_base.ln882x-ard.yaml index 80fc6690f9..34abcb5a77 100644 --- a/tests/test_build_components/build_components_base.ln882x-ard.yaml +++ b/tests/test_build_components/build_components_base.ln882x-ard.yaml @@ -3,7 +3,7 @@ esphome: friendly_name: $component_name ln882x: - board: generic-ln882hki + board: generic-ln882h logger: level: VERY_VERBOSE diff --git a/tests/test_build_components/build_components_base.nrf52-microbit.yaml b/tests/test_build_components/build_components_base.nrf52-microbit.yaml new file mode 100644 index 0000000000..37728b4b64 --- /dev/null +++ b/tests/test_build_components/build_components_base.nrf52-microbit.yaml @@ -0,0 +1,16 @@ +esphome: + name: componenttestnrf52 + friendly_name: $component_name + +nrf52: + board: bbc_microbit + +logger: + level: VERY_VERBOSE + hardware_uart: UART0 + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/build_components_base.rp2040-ard.yaml b/tests/test_build_components/build_components_base.rp2040-ard.yaml index 4fb8d51333..4d26a38b69 100644 --- a/tests/test_build_components/build_components_base.rp2040-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2040-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040ard friendly_name: $component_name -rp2040: +rp2: board: rpipicow logger: diff --git a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml similarity index 97% rename from tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml rename to tests/test_build_components/build_components_base.rp2350-ard.yaml index 0922a5238e..5df1670862 100644 --- a/tests/test_build_components/build_components_base.rp2040-pico2-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,7 +2,7 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name -rp2040: +rp2: board: rpipico2 logger: diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index 5e925d0067..a3c6f476e0 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -45,14 +45,13 @@ common/ ## How It Works ### Component Test Structure -Each component test includes the common bus config: +Each component test includes the common bus config and its own `common.yaml` through dict-style `packages:`. Always use packages for every include — the grouping scripts only understand dict-style packages, so list-style packages or top-level `<<:` merge keys prevent correct batch grouping. Key the bus package by the bus name and the component's `common.yaml` by the component name: ```yaml # tests/components/bh1750/test.esp32-idf.yaml packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + bh1750: !include common.yaml ``` The common config provides: diff --git a/tests/test_build_components/common/spi/rp2040-pico2-ard.yaml b/tests/test_build_components/common/spi/rp2350-ard.yaml similarity index 100% rename from tests/test_build_components/common/spi/rp2040-pico2-ard.yaml rename to tests/test_build_components/common/spi/rp2350-ard.yaml 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/analyze_memory/test_ram_strings.py b/tests/unit_tests/analyze_memory/test_ram_strings.py new file mode 100644 index 0000000000..dda793a7f1 --- /dev/null +++ b/tests/unit_tests/analyze_memory/test_ram_strings.py @@ -0,0 +1,123 @@ +"""Tests for RAM symbol analysis in the RAM strings analyzer.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome.analyze_memory.ram_strings import RamStringsAnalyzer, SectionInfo + +# nm -S --size-sort output with the newlib lock mutexes: nine global +# symbols that are all aliases of two local StaticSemaphore_t objects. +NM_OUTPUT_WITH_ALIASES = """\ +3ffb4400 00000010 B small_symbol +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___env_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +3ffb43c8 00000054 B __lock___sfp_recursive_mutex +3ffb43c8 00000054 B __lock___sinit_recursive_mutex +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb441c 00000054 B __lock___arc4random_mutex +3ffb441c 00000054 B __lock___at_quick_exit_mutex +3ffb441c 00000054 B __lock___dd_hash_mutex +3ffb441c 00000054 B __lock___tz_mutex +3ffb441c 00000054 b s_common_mutex +""" + + +def _make_analyzer(tmp_path) -> RamStringsAnalyzer: + """Create an analyzer with a dummy ELF and a .dram0.bss section.""" + elf = tmp_path / "firmware.elf" + elf.write_bytes(b"\x7fELF") + analyzer = RamStringsAnalyzer(str(elf), platform="esp32") + analyzer.sections[".dram0.bss"] = SectionInfo(".dram0.bss", 0x3FFB0000, 0x10000) + return analyzer + + +def _run_symbol_analysis(analyzer: RamStringsAnalyzer, nm_output: str) -> None: + """Run _analyze_symbols with mocked nm output.""" + with ( + patch( + "esphome.analyze_memory.ram_strings.find_tool", + return_value="nm", + ), + patch.object(analyzer, "_run_command", return_value=nm_output), + ): + analyzer._analyze_symbols() + + +def test_aliased_symbols_counted_once(tmp_path: Path) -> None: + """Symbols sharing an address are one object, not one per name.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + # Three distinct addresses, so three symbols + assert len(analyzer.ram_symbols) == 3 + total = sum(s.size for s in analyzer.ram_symbols) + assert total == 0x10 + 0x54 + 0x54 + + +def test_aliases_recorded_on_first_symbol(tmp_path: Path) -> None: + """Extra names at the same address are kept as aliases.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + by_addr = {s.address: s for s in analyzer.ram_symbols} + assert len(by_addr[0x3FFB43C8].aliases) == 5 + assert len(by_addr[0x3FFB441C].aliases) == 4 + assert by_addr[0x3FFB4400].aliases == [] + assert "s_common_mutex" in by_addr[0x3FFB441C].aliases + + +def test_alias_count_shown_in_report(tmp_path: Path) -> None: + """The large symbols table notes how many aliases were merged.""" + analyzer = _make_analyzer(tmp_path) + _run_symbol_analysis(analyzer, NM_OUTPUT_WITH_ALIASES) + + report = analyzer.generate_report() + assert "(+5 aliases)" in report + assert "(+4 aliases)" in report + # Each lock name appears at most once in the report + assert report.count("__lock___") == 2 + + +def test_global_name_preferred_over_local_alias(tmp_path: Path) -> None: + """A global name becomes the primary even when nm lists a local first.""" + analyzer = _make_analyzer(tmp_path) + nm_output = """\ +3ffb43c8 00000054 b s_common_recursive_mutex +3ffb43c8 00000054 B __lock___atexit_recursive_mutex +3ffb43c8 00000054 B __lock___malloc_recursive_mutex +""" + _run_symbol_analysis(analyzer, nm_output) + + (symbol,) = analyzer.ram_symbols + assert symbol.name == "__lock___atexit_recursive_mutex" + assert symbol.sym_type == "B" + assert sorted(symbol.aliases) == [ + "__lock___malloc_recursive_mutex", + "s_common_recursive_mutex", + ] + + +def test_alias_note_survives_name_truncation(tmp_path: Path) -> None: + """Long names are truncated but the alias note is kept intact.""" + analyzer = _make_analyzer(tmp_path) + long_name = "a_very_long_symbol_name_that_exceeds_the_column_width_by_far" + nm_output = f"""\ +3ffb43c8 00000054 B {long_name} +3ffb43c8 00000054 B other_name +""" + _run_symbol_analysis(analyzer, nm_output) + + report = analyzer.generate_report() + row = next(line for line in report.splitlines() if "(+1 aliases)" in line) + name_column = row[:50].rstrip() + assert name_column.endswith("(+1 aliases)") + assert name_column.startswith("a_very_long_symbol_name") + + +def test_symbols_outside_ram_sections_skipped(tmp_path: Path) -> None: + """Symbols outside known RAM sections are ignored entirely.""" + analyzer = _make_analyzer(tmp_path) + nm_output = "40080000 00000100 B not_in_ram\n" + _run_symbol_analysis(analyzer, nm_output) + assert analyzer.ram_symbols == [] diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index a5c2719f42..bcd9fa655a 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: @@ -195,6 +243,7 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), patch.object(CORE, "name", "test"), patch.object(CORE, "cpp_standard", None), + patch.object(CORE, "cxx_build_flags", set()), ): from esphome.build_gen.espidf import get_project_cmakelists @@ -203,6 +252,28 @@ def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: assert "CXX_COMPILE_OPTIONS" not in content +def test_get_project_cmakelists_cxx_build_flags(tmp_path: Path) -> None: + """Flags registered via cg.add_cxx_build_flag() are appended to + CXX_COMPILE_OPTIONS (C++-only, GCC warns if they reach C compiles) + 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", None), + patch.object(CORE, "cxx_build_flags", {"-Wno-volatile"}), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + flag_line = 'idf_build_set_property(CXX_COMPILE_OPTIONS "-Wno-volatile" APPEND)' + assert flag_line in content + include_pos = content.index("tools/cmake/project.cmake") + flag_pos = content.index(flag_line) + project_pos = content.index("project(test)") + assert include_pos < flag_pos < project_pos + + 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.""" diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index 2ae3836a25..3df2fb1036 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -200,3 +200,32 @@ def test_get_ini_content_no_cpp_standard( content = platformio.get_ini_content() assert "-std=" not in content + + +def test_write_cxx_flags_script_emits_registered_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Flags registered via cg.add_cxx_build_flag() are emitted as CXXFLAGS, + sorted, so they apply to C++ compiles only.""" + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", {"-Wno-volatile", "-Wno-deprecated"}) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert ( + 'env.Append(CXXFLAGS=["-Wno-deprecated"])\n' + 'env.Append(CXXFLAGS=["-Wno-volatile"])\n' + ) in content + + +def test_write_cxx_flags_script_no_flags( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.build_path = str(tmp_path) + monkeypatch.setattr(CORE, "cxx_build_flags", set()) + + platformio.write_cxx_flags_script() + + content = (tmp_path / platformio.CXX_FLAGS_FILE_NAME).read_text() + assert "CXXFLAGS" not in content diff --git a/tests/unit_tests/components/test_libretiny.py b/tests/unit_tests/components/test_libretiny.py new file mode 100644 index 0000000000..ee00bdc180 --- /dev/null +++ b/tests/unit_tests/components/test_libretiny.py @@ -0,0 +1,52 @@ +"""Tests for LibreTiny board detection, including renamed-board migration.""" + +import pytest + +from esphome.components.libretiny import _detect_variant +from esphome.components.libretiny.const import ( + FAMILY_LN882H, + KEY_COMPONENT_DATA, + KEY_LIBRETINY, +) +from esphome.components.ln882x import COMPONENT_DATA +import esphome.config_validation as cv +from esphome.const import CONF_BOARD, CONF_FAMILY +from esphome.core import CORE + + +@pytest.fixture +def ln882x_core_data() -> None: + """Populate CORE the way the ln882x component schema does.""" + CORE.data[KEY_LIBRETINY] = {KEY_COMPONENT_DATA: COMPONENT_DATA} + + +def test_detect_variant_known_board_passes(ln882x_core_data: None) -> None: + """A current board id resolves its family without warnings.""" + result = _detect_variant({CONF_BOARD: "generic-ln882h"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + + +def test_detect_variant_renamed_board_migrates( + ln882x_core_data: None, caplog: pytest.LogCaptureFixture +) -> None: + """A pre-rename board id validates against the new id, with a warning.""" + result = _detect_variant({CONF_BOARD: "generic-ln882hki"}) + assert result[CONF_BOARD] == "generic-ln882h" + assert result[CONF_FAMILY] == FAMILY_LN882H + assert "renamed to 'generic-ln882h'" in caplog.text + + +def test_detect_variant_renamed_board_does_not_mutate_input( + ln882x_core_data: None, +) -> None: + """Migration copies the config; the caller's dict keeps the old id.""" + value = {CONF_BOARD: "generic-ln882hki"} + _detect_variant(value) + assert value[CONF_BOARD] == "generic-ln882hki" + + +def test_detect_variant_unknown_board_still_raises(ln882x_core_data: None) -> None: + """Ids outside the rename map keep the family-override error.""" + with pytest.raises(cv.Invalid, match="This board is unknown"): + _detect_variant({CONF_BOARD: "not-a-real-board"}) diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py new file mode 100644 index 0000000000..023d926dc4 --- /dev/null +++ b/tests/unit_tests/components/test_rp2.py @@ -0,0 +1,95 @@ +"""Tests for the ``rp2`` target-platform component. + +``rp2`` is the canonical name for the Raspberry Pi RP-series target +platform. ``rp2040`` is a deprecated alias declared via +``ALIASES = ["rp2040"]`` on the rp2 component — the framework +(see ``esphome/loader.py`` and ``esphome/config.py``) handles both +Python-import aliasing (via a ``sys.meta_path`` finder) and YAML-key +aliasing (via a pre-pass in ``validate_config``), so there is no +hand-rolled shim in ``esphome/components/rp2040/``. + +These tests pin down the canonical board helpers; the alias contract +itself (Python imports, YAML key rename, deprecation warning) is covered +by the framework tests under ``tests/unit_tests/``. +""" + + +def test_board_id_has_wifi_for_known_wifi_board() -> None: + """``rpipicow`` is the canonical Pico W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipicow") is True + + +def test_board_id_has_wifi_for_known_non_wifi_board() -> None: + """Plain ``rpipico`` has no CYW43 → False.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico") is False + + +def test_board_id_has_wifi_for_rp2350_w_variant() -> None: + """``rpipico2w`` is the RP2350 Pico 2 W → True.""" + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("rpipico2w") is True + + +def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: + """Unknown ids fail open so a custom board is not rejected. + + The validator falls back to ESPHome's compile-time check; the + helper returning True here means the wizard emits a ``wifi:`` + block and any genuinely-unsupported config trips the existing + "no CYW43" guard at compile time. + """ + from esphome.components import rp2 + + assert rp2.board_id_has_wifi("not-a-real-board-id") is True + + +def test_rp2_declares_rp2040_as_alias() -> None: + """The framework-level deprecation hook is on the ``rp2`` component. + + The legacy ``rp2040:`` YAML key works because the rp2 component + opts in via ``ALIASES``; without this declaration the rename + framework wouldn't route legacy configs. + """ + from esphome.components import rp2 + + assert "rp2040" in rp2.ALIASES + assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" + + +def test_rp2040_python_import_resolves_to_rp2() -> None: + """``from esphome.components import rp2040`` must work for external + custom components and external tooling (device-builder, the dashboard + wizard, etc.) that still import from the legacy module path. + + The ``_AliasFinder`` on ``sys.meta_path`` rewrites the lookup to + the canonical module — both should be the same object. + """ + from esphome.components import ( + rp2, + rp2040, # routed via _AliasFinder + ) + + assert rp2040 is rp2 + + +def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: + """Submodule imports (e.g. ``esphome.components.rp2040.boards``) must + also route to the canonical equivalents — the board-generator script + and the dashboard wizard both rely on this path. + """ + from esphome.components.rp2 import ( + boards as rp2_boards, + generate_boards as rp2_generate, + ) + from esphome.components.rp2040 import ( + boards as rp2040_boards, + generate_boards as rp2040_generate, + ) + + assert rp2040_boards is rp2_boards + assert rp2040_generate is rp2_generate diff --git a/tests/unit_tests/components/test_rp2040.py b/tests/unit_tests/components/test_rp2040.py deleted file mode 100644 index 8e726933ed..0000000000 --- a/tests/unit_tests/components/test_rp2040.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Tests for RP2040 component public helpers and variant detection.""" - -import pytest - -from esphome.components.rp2040 import _detect_variant, board_id_has_wifi -from esphome.components.rp2040.const import VARIANT_RP2040, VARIANT_RP2350 -import esphome.config_validation as cv -from esphome.const import CONF_BOARD, CONF_VARIANT - - -def test_board_id_has_wifi_for_known_wifi_board() -> None: - """``rpipicow`` is the canonical Pico W → True.""" - assert board_id_has_wifi("rpipicow") is True - - -def test_board_id_has_wifi_for_known_non_wifi_board() -> None: - """Plain ``rpipico`` has no CYW43 → False.""" - assert board_id_has_wifi("rpipico") is False - - -def test_board_id_has_wifi_for_rp2350_w_variant() -> None: - """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - assert board_id_has_wifi("rpipico2w") is True - - -def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: - """Unknown ids fail open so a custom board is not rejected. - - The validator falls back to ESPHome's compile-time check; the - helper returning True here means the wizard emits a ``wifi:`` - block and any genuinely-unsupported config trips the existing - "no CYW43" guard at compile time. - """ - assert board_id_has_wifi("not-a-real-board-id") is True - - -def test_detect_variant_derives_variant_from_board() -> None: - """Board alone resolves to the matching variant.""" - result = _detect_variant({CONF_BOARD: "rpipicow"}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_derives_variant_from_rp2350_board() -> None: - """An RP2350 board resolves to ``RP2350``.""" - result = _detect_variant({CONF_BOARD: "rpipico2"}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_only_picks_default_board_rp2040() -> None: - """Variant alone picks Pico W as the canonical RP2040 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2040}) - assert result[CONF_BOARD] == "rpipicow" - assert result[CONF_VARIANT] == VARIANT_RP2040 - - -def test_detect_variant_only_picks_default_board_rp2350() -> None: - """Variant alone picks Pico 2 W as the canonical RP2350 board.""" - result = _detect_variant({CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2w" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_matching_explicit_variant_passes() -> None: - """Specifying both a board and the matching variant is allowed.""" - result = _detect_variant({CONF_BOARD: "rpipico2", CONF_VARIANT: VARIANT_RP2350}) - assert result[CONF_BOARD] == "rpipico2" - assert result[CONF_VARIANT] == VARIANT_RP2350 - - -def test_detect_variant_mismatched_variant_raises() -> None: - """Board/variant mismatch must be rejected and name the offending board.""" - with pytest.raises( - cv.Invalid, match=r"does not match the selected board 'rpipicow'" - ): - _detect_variant({CONF_BOARD: "rpipicow", CONF_VARIANT: VARIANT_RP2350}) - - -def test_detect_variant_unknown_board_without_variant_raises() -> None: - """Unknown board with no variant tells the user how to recover.""" - with pytest.raises(cv.Invalid, match="please specify the chip variant"): - _detect_variant({CONF_BOARD: "not-a-real-board"}) - - -def test_detect_variant_unknown_board_with_variant_passes() -> None: - """Unknown board + explicit variant is accepted (with a warning).""" - result = _detect_variant( - {CONF_BOARD: "not-a-real-board", CONF_VARIANT: VARIANT_RP2040} - ) - assert result[CONF_BOARD] == "not-a-real-board" - assert result[CONF_VARIANT] == VARIANT_RP2040 diff --git a/tests/unit_tests/components/test_rp2040_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py similarity index 98% rename from tests/unit_tests/components/test_rp2040_generate_boards.py rename to tests/unit_tests/components/test_rp2_generate_boards.py index 551e88f6f6..68bbada59b 100644 --- a/tests/unit_tests/components/test_rp2040_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -1,4 +1,4 @@ -"""Tests for rp2040 generate_boards.py.""" +"""Tests for rp2 generate_boards.py.""" from __future__ import annotations @@ -8,7 +8,7 @@ import textwrap import pytest -from esphome.components.rp2040.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once diff --git a/tests/unit_tests/components/test_time.py b/tests/unit_tests/components/test_time.py index 5ae9d787d6..6f3b4bb14f 100644 --- a/tests/unit_tests/components/test_time.py +++ b/tests/unit_tests/components/test_time.py @@ -1,6 +1,8 @@ """Tests for time component cron expression parsing.""" import errno +import subprocess +import sys from unittest.mock import MagicMock, patch import pytest @@ -143,3 +145,43 @@ def test_validate_tz_accepts_posix_string_when_read_bytes_raises_einval() -> Non _mock_resources_with_error(OSError(errno.EINVAL, "Invalid argument")), ): assert validate_tz("<+08>-8") == "<+08>-8" + + +def _modules_after(code: str) -> set[str]: + """Run code in a fresh interpreter and return the imported module names. + + A subprocess is required because the test process itself has already + imported aioesphomeapi via other tests, so sys.modules here is useless. + """ + result = subprocess.run( + [sys.executable, "-c", f"import sys\n{code}\nprint('\\n'.join(sys.modules))"], + capture_output=True, + text=True, + check=True, + ) + return set(result.stdout.split()) + + +def test_importing_time_does_not_import_aioesphomeapi() -> None: + """Importing the time component must not drag in aioesphomeapi. + + aioesphomeapi is a heavy import (it builds a large number of dataclasses at + import time). The time component is auto-loaded by many components, so + importing it for its schema during config validation must not pay that + cost. The import is deferred to the functions that actually need it. + """ + modules = _modules_after("import esphome.components.time") + assert "aioesphomeapi" not in modules + + +def test_validate_tz_imports_aioesphomeapi_lazily() -> None: + """Validating a non-empty timezone is what triggers the lazy import. + + Documents the boundary: the cost is only paid when a timezone is actually + validated, not merely by loading the component. + """ + modules = _modules_after( + "from esphome.components.time import validate_tz\n" + "validate_tz('EST5EDT,M3.2.0,M11.1.0')" + ) + assert "aioesphomeapi" in modules diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 9598c1bdd8..3899b3d854 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -87,8 +87,8 @@ def test_has_native_wifi_esp32_variant_case_insensitive() -> None: def test_has_native_wifi_dispatches_rp2040_to_board_check() -> None: """RP2040 platform routes through ``rp2040.board_id_has_wifi``.""" - assert has_native_wifi(platform=Platform.RP2040, board="rpipicow") is True - assert has_native_wifi(platform=Platform.RP2040, board="rpipico") is False + assert has_native_wifi(platform=Platform.RP2, board="rpipicow") is True + assert has_native_wifi(platform=Platform.RP2, board="rpipico") is False def test_has_native_wifi_returns_false_for_nrf52() -> None: @@ -134,7 +134,7 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None: def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None: """RP2040 without a board id falls open to True (custom-board default).""" - assert has_native_wifi(platform=Platform.RP2040) is True + assert has_native_wifi(platform=Platform.RP2) is True def _wifi_config( diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index b3d87f6857..0362c40bce 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1113,6 +1113,48 @@ def test_config_hash_different_for_different_configs() -> None: assert hash1 != hash2 +def test_config_hash_ignores_build_path() -> None: + """Test that config_hash does not depend on the build_path value. + + build_path embeds ESPHOME_BUILD_PATH and OS path separators, so it must + not make the hash differ between machines. + """ + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "build\\test"}} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config = {"esphome": {"name": "test", "build_path": "/build/test"}} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + +def test_config_hash_same_for_different_config_dirs(tmp_path: Path) -> None: + """Test that Path values under the config dir hash the same everywhere. + + Simulates the same project checked out at two different locations; the + absolute paths differ but the layout relative to the config dir is the + same, so the hashes must match. + """ + dir1 = tmp_path / "machine_a" / "project" + dir2 = tmp_path / "machine_b" / "somewhere" / "else" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + CORE.reset() + CORE.config_path = dir1 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir1 / "fonts" / "arial.ttf"} + hash1 = CORE.config_hash + + CORE.reset() + CORE.config_path = dir2 / "device.yaml" + CORE.config = {"esphome": {"name": "test"}, "file": dir2 / "fonts" / "arial.ttf"} + hash2 = CORE.config_hash + + assert hash1 == hash2 + + def test_make_app_name_cpp_no_mac_simple() -> None: """Test simple name without MAC suffix returns string literal.""" cpp_expr, global_decl, byte_len = make_app_name_cpp( @@ -1307,3 +1349,34 @@ async def test_to_code_adds_libraries(yaml_file: Callable[[str], Path]) -> None: mock_cg.add_library.assert_any_call( "noise-c", None, "https://github.com/esphome/noise-c.git" ) + + +def test_esphome_build_internals_are_yaml_only() -> None: + """Raw build-system inputs in the ``esphome:`` block are ``YAML_ONLY``. + + These knobs (compiler flags, raw PlatformIO options, C/C++ includes, + libraries, build host parallelism, the min-version gate, …) are not + meaningful as visual-editor form fields and a wrong value breaks the + build, so they must never render in a schema-aware UI. + """ + # CONFIG_SCHEMA is cv.All(cv.Schema({...}), validate_hostname). + inner = config.CONFIG_SCHEMA.validators[0].schema + markers = {str(k): k for k in inner} + yaml_only_fields = { + CONF_BUILD_PATH, + "platformio_options", + "build_flags", + "environment_variables", + "includes", + "includes_c", + "libraries", + "debug_scheduler", + } + for field in yaml_only_fields: + assert markers[field].visibility is cv.Visibility.YAML_ONLY, field + # Packaging / build-host knobs are real but rarely-touched overrides: + # surface them under the editor's advanced disclosure, not yaml-only. + for field in ("min_version", "compile_process_limit"): + assert markers[field].visibility is cv.Visibility.ADVANCED, field + # A regular device-config field stays on the main form. + assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index e79ff850f9..3ac4ce27af 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -174,10 +174,11 @@ def test_empty_name_fallback() -> None: def test_name_add_mac_suffix_behavior() -> None: """Test behavior related to name_add_mac_suffix. - In C++, when name_add_mac_suffix is enabled and entity has no name, - get_object_id() returns str_sanitize(str_snake_case(App.get_friendly_name())) - dynamically. Our function always returns the same result since we're - calculating the base for duplicate tracking. + In C++, an entity's object_id is computed from its name_ via + write_object_id_to() (sanitized snake_case). When an entity has no name, + configure_entity_() sets name_ from the friendly name, with the MAC suffix + appended when name_add_mac_suffix is enabled. Our function always returns + the same result since we're calculating the base for duplicate tracking. """ # The function should always return the same result regardless of # name_add_mac_suffix setting, as we're calculating the base object_id diff --git a/tests/unit_tests/test_config_normalization.py b/tests/unit_tests/test_config_normalization.py index 4ec17b3c7c..c8b7b63094 100644 --- a/tests/unit_tests/test_config_normalization.py +++ b/tests/unit_tests/test_config_normalization.py @@ -1,13 +1,15 @@ """Unit tests for esphome.config module.""" -from collections.abc import Generator +from collections.abc import Callable, Generator +import logging from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config, yaml_util -from esphome.core import CORE +from esphome.core import CORE, AutoLoad +from esphome.types import ConfigType @pytest.fixture @@ -113,3 +115,137 @@ def test_ota_with_platform_list_and_captive_portal(fixtures_dir: Path) -> None: platforms = {p.get("platform") for p in result["ota"]} assert "esphome" in platforms, f"Expected esphome platform in {platforms}" assert "web_server" in platforms, f"Expected web_server platform in {platforms}" + + +# --------------------------------------------------------------------------- +# LEGACY_CONFIG_MIGRATE hook on LoadValidationStep -- the removable shim that +# lets a platform component rewrite a pre-platform top-level config. +# --------------------------------------------------------------------------- + + +def _run_load_step( + domain: str, + conf: object, + migrate: Callable[[ConfigType], list | None] | None, +) -> config.Config: + """Run a LoadValidationStep for a platform component with a given migrate hook.""" + component = Mock() + component.is_platform_component = True + component.multi_conf_no_default = False + component.legacy_config_migrate = migrate + + result = config.Config() + with ( + patch("esphome.config.get_component", return_value=component), + patch("esphome.config._process_auto_load"), + patch("esphome.config._process_platform_config"), + ): + config.LoadValidationStep(domain, conf).run(result) + return result + + +def test_legacy_migrate_rewrites_conf() -> None: + """A legacy config that the hook migrates is replaced with the new list.""" + migrated = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=migrated) + + result = _run_load_step("image", [{"id": "a", "file": "x.png"}], migrate) + + migrate.assert_called_once_with([{"id": "a", "file": "x.png"}]) + assert result["image"] == migrated + + +def test_legacy_migrate_none_keeps_new_format() -> None: + """When the hook returns None the already-new config is left untouched.""" + new_format = [{"platform": "file", "id": "a"}] + migrate = Mock(return_value=None) + + result = _run_load_step("image", new_format, migrate) + + migrate.assert_called_once_with(new_format) + assert result["image"] == new_format + + +def test_legacy_migrate_absent_hook_is_noop() -> None: + """A platform component without the hook normalizes without migration.""" + result = _run_load_step("image", {"id": "a"}, None) + + # Bare dict still gets wrapped into a list by the normal normalization path. + assert result["image"] == [{"id": "a"}] + + +def test_legacy_migrate_skipped_for_empty_conf() -> None: + """An empty config short-circuits before the hook is consulted.""" + migrate = Mock(return_value=[{"platform": "file"}]) + + result = _run_load_step("image", [], migrate) + + migrate.assert_not_called() + assert result["image"] == [] + + +def test_legacy_migrate_skipped_for_autoload() -> None: + """An auto-loaded (AutoLoad) config is never migrated.""" + migrate = Mock(return_value=[{"platform": "file"}]) + auto = AutoLoad() + auto["id"] = "a" + + result = _run_load_step("image", auto, migrate) + + migrate.assert_not_called() + # AutoLoad is dict-like, so normalization wraps it into a single-entry list. + assert result["image"] == [auto] + + +def _write_merge_conflict_config(tmp_path: Path, *, suppress: bool) -> Path: + """Create a config where two `<<` includes both define `logger:`. + + The second `logger:` is dropped by the shallow merge. Returns the main file. + """ + (tmp_path / "a.yaml").write_text("logger:\n level: DEBUG\n") + (tmp_path / "b.yaml").write_text("logger:\n level: INFO\n") + esphome_section = "esphome:\n name: test\n" + if suppress: + esphome_section += " merge_warnings: false\n" + main = tmp_path / "main.yaml" + main.write_text(f"{esphome_section}<<: !include a.yaml\n<<: !include b.yaml\n") + return main + + +def test_validate_config_warns_on_dropped_merge_key( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """By default, a `<<` merge that drops a key logs a warning.""" + main = _write_merge_conflict_config(tmp_path, suppress=False) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert any( + "was dropped while processing a '<<' merge" in record.message + and "logger" in record.message + for record in caplog.records + ) + # The queue is drained so the warning cannot leak into a later run. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_validate_config_suppresses_merge_warning( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """`esphome: merge_warnings: false` hides the warning but still drains the queue.""" + main = _write_merge_conflict_config(tmp_path, suppress=True) + CORE.config_path = main + raw_config = yaml_util.load_yaml(main) + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + config.validate_config(raw_config, {}) + + assert not any( + "was dropped while processing a '<<' merge" in record.message + for record in caplog.records + ) + # The queue is drained even when the warning is suppressed. + assert yaml_util.take_dropped_merge_keys() == [] diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 74d9a5047a..17dfaad9b8 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,11 +1,12 @@ +from pathlib import Path import string -from hypothesis import example, given +from hypothesis import example, given, settings from hypothesis.strategies import builds, integers, ip_addresses, one_of, text import pytest import voluptuous as vol -from esphome import config_validation +from esphome import config_validation as cv from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, @@ -17,27 +18,59 @@ from esphome.components.esp32 import ( ) from esphome.config_validation import Invalid from esphome.const import ( + CONF_DAY, + CONF_HOUR, + CONF_ID, + CONF_INTERNAL, + CONF_MINUTE, + CONF_MONTH, + CONF_NAME, + CONF_REF, + CONF_SECOND, + CONF_TYPE, + CONF_VALUE, + CONF_YEAR, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_HOST, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, SCHEDULER_DONT_RUN, + TYPE_GIT, + TYPE_LOCAL, + Framework, ) -from esphome.core import CORE, HexInt, Lambda -from esphome.yaml_util import SensitiveStr +from esphome.core import ( + CORE, + ID, + HexInt, + Lambda, + MACAddress, + TimePeriod, + TimePeriodMicroseconds, + TimePeriodMinutes, + TimePeriodNanoseconds, + TimePeriodSeconds, +) +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.util import Registry +from esphome.yaml_util import ESPHomeDataBase, SensitiveStr, make_data_base def test_check_not_templatable__invalid(): with pytest.raises(Invalid, match="This option is not templatable!"): - config_validation.check_not_templatable(Lambda("")) + cv.check_not_templatable(Lambda("")) @pytest.mark.parametrize("value", ("foo", 1, "D12", False)) def test_alphanumeric__valid(value): - actual = config_validation.alphanumeric(value) + actual = cv.alphanumeric(value) assert actual == str(value) @@ -45,12 +78,12 @@ def test_alphanumeric__valid(value): @pytest.mark.parametrize("value", ("£23", "Foo!")) def test_alphanumeric__invalid(value): with pytest.raises(Invalid): - config_validation.alphanumeric(value) + cv.alphanumeric(value) @given(value=text(alphabet=string.ascii_lowercase + string.digits + "-_")) def test_valid_name__valid(value): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value @@ -58,29 +91,29 @@ def test_valid_name__valid(value): @pytest.mark.parametrize("value", ("foo bar", "FooBar", "foo::bar")) def test_valid_name__invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) def test_valid_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - actual = config_validation.valid_name(value) + actual = cv.valid_name(value) @pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) def test_valid_name__substitution_like_invalid(value): with pytest.raises(Invalid): - config_validation.valid_name(value) + cv.valid_name(value) @pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) def test_validate_id_name__valid(value): - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value @@ -88,23 +121,23 @@ def test_validate_id_name__valid(value): @pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) def test_validate_id_name__invalid(value): with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) def test_validate_id_name__substitution_valid(value): CORE.vscode = True - actual = config_validation.validate_id_name(value) + actual = cv.validate_id_name(value) assert actual == value CORE.vscode = False with pytest.raises(Invalid): - config_validation.validate_id_name(value) + cv.validate_id_name(value) @given(one_of(integers(), text())) def test_string__valid(value): - actual = config_validation.string(value) + actual = cv.string(value) assert actual == str(value) @@ -112,12 +145,12 @@ def test_string__valid(value): @pytest.mark.parametrize("value", ({}, [], True, False, None)) def test_string__invalid(value): with pytest.raises(Invalid): - config_validation.string(value) + cv.string(value) @given(text()) def test_strict_string__valid(value): - actual = config_validation.string_strict(value) + actual = cv.string_strict(value) assert actual == value @@ -125,29 +158,29 @@ def test_strict_string__valid(value): @pytest.mark.parametrize("value", (None, 123)) def test_string_string__invalid(value): with pytest.raises(Invalid, match="Must be string, got"): - config_validation.string_strict(value) + cv.string_strict(value) def test_sensitive__default_delegates_to_string() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) - assert validator.inner is config_validation.string + assert isinstance(validator, cv.SensitiveValidator) + assert validator.inner is cv.string assert validator("hunter2") == "hunter2" assert validator(42) == "42" def test_sensitive__custom_inner_delegates_validation() -> None: - validator = config_validation.sensitive(config_validation.string_strict) + validator = cv.sensitive(cv.string_strict) - assert validator.inner is config_validation.string_strict + assert validator.inner is cv.string_strict assert validator("abc") == "abc" with pytest.raises(Invalid, match="Must be string, got"): validator(123) def test_sensitive__wraps_string_result_in_sensitive_str() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() result = validator("hunter2") assert isinstance(result, SensitiveStr) @@ -164,7 +197,7 @@ def test_sensitive__does_not_double_tag_already_sensitive() -> None: def inner(_value): return pre_tagged - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) result = validator("anything") assert result is pre_tagged @@ -178,14 +211,93 @@ def test_sensitive__non_string_result_passes_through() -> None: def inner(_value): return sentinel - validator = config_validation.sensitive(inner) + validator = cv.sensitive(inner) assert validator("anything") is sentinel def test_sensitive__is_detectable_via_isinstance() -> None: - validator = config_validation.sensitive() + validator = cv.sensitive() - assert isinstance(validator, config_validation.SensitiveValidator) + assert isinstance(validator, cv.SensitiveValidator) + + +def test_bind_key__bare_usage_validates_and_is_sensitive() -> None: + # Used bare (cv.bind_key) it is itself a sensitive validator: detectable for + # frontend masking and validating a value directly tags the result. + assert isinstance(cv.bind_key, cv.SensitiveValidator) + + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF") + + assert isinstance(result, SensitiveStr) + assert result == "0123456789ABCDEF0123456789ABCDEF" + + +def test_bind_key__bare_usage_in_schema() -> None: + # Voluptuous calls the bare validator with the config value; the result must + # come through tagged sensitive. + schema = cv.Schema({cv.Required("key"): cv.bind_key}) + out = schema({"key": "0123456789ABCDEF0123456789ABCDEF"}) + + assert isinstance(out["key"], SensitiveStr) + + +def test_bind_key__factory_returns_sensitive_validator() -> None: + # Called with a name (cv.bind_key(name=...)) it returns a new sensitive + # validator rather than validating. + validator = cv.bind_key(name="Decryption key") + + assert isinstance(validator, cv.SensitiveValidator) + assert validator is not cv.bind_key + assert isinstance(validator("0123456789ABCDEF0123456789ABCDEF"), SensitiveStr) + + +@pytest.mark.parametrize( + ("value", "error"), + ( + ("00", "Decryption key must consist of 16 hexadecimal numbers"), + ("0123456789ABCDEF0123456789ABCDEG", "Decryption key must be hex values"), + ), +) +def test_bind_key__custom_name_in_error(value: str, error: str) -> None: + # The ``name`` argument (used by dsmr/dlms_meter) customizes error messages. + validator = cv.bind_key(name="Decryption key") + with pytest.raises(Invalid, match=error): + validator(value) + + +def test_bind_key__rejects_non_hex_pair_length() -> None: + # Odd-length input yields a trailing single-char part, hitting the + # "format XX" branch rather than the hex-value branch. + with pytest.raises(Invalid, match="Bind key must be format XX"): + cv.bind_key("0123456789ABCDEF0123456789ABCDE") + + +def test_bind_key__direct_call_with_name_validates_with_that_name() -> None: + # Passing both a value and a name validates immediately using the custom + # name for error wording, and still tags the result sensitive. + result = cv.bind_key("0123456789ABCDEF0123456789ABCDEF", name="Decryption key") + assert isinstance(result, SensitiveStr) + + with pytest.raises(Invalid, match="Decryption key must consist of"): + cv.bind_key("00", name="Decryption key") + + +def test_bind_key__factory_without_name_keeps_existing_name() -> None: + # Re-invoking a named validator without a name preserves its name rather + # than resetting to the default. + named = cv.bind_key(name="Decryption key") + rederived = named() + + with pytest.raises(Invalid, match="Decryption key must consist of"): + rederived("00") + + +def test_bind_key__repr_is_name_keyed_and_non_recursive() -> None: + # ``self.inner`` is a bound method of the instance, so the inherited + # ``repr(self.inner)`` would recurse infinitely; the override keeps repr + # finite and keyed on the name for schema-dump dedup. + assert repr(cv.bind_key) == "bind_key('Bind key')" + assert repr(cv.bind_key(name="Decryption key")) == "bind_key('Decryption key')" def test_sensitive__repr_mirrors_inner() -> None: @@ -193,18 +305,14 @@ def test_sensitive__repr_mirrors_inner() -> None: # validator's repr keeps two ``cv.sensitive(cv.string)`` wrappers # interchangeable for that purpose and avoids leaking the wrapper as # noise in voluptuous error messages. - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.string - ) - assert repr(config_validation.sensitive(config_validation.string)) == repr( - config_validation.sensitive(config_validation.string) - ) + assert repr(cv.sensitive(cv.string)) == repr(cv.string) + assert repr(cv.sensitive(cv.string)) == repr(cv.sensitive(cv.string)) def test_sensitive_key_fragments__covers_common_terms() -> None: - assert isinstance(config_validation.SENSITIVE_KEY_FRAGMENTS, frozenset) + assert isinstance(cv.SENSITIVE_KEY_FRAGMENTS, frozenset) for term in ("password", "passcode", "secret", "token", "api_key", "apikey", "psk"): - assert term in config_validation.SENSITIVE_KEY_FRAGMENTS + assert term in cv.SENSITIVE_KEY_FRAGMENTS @given( @@ -219,31 +327,31 @@ def test_sensitive_key_fragments__covers_common_terms() -> None: ) @example("") def test_icon__valid(value): - actual = config_validation.icon(value) + actual = cv.icon(value) assert actual == value def test_icon__invalid(): with pytest.raises(Invalid, match="Icons must match the format "): - config_validation.icon("foo") + cv.icon("foo") def test_icon__max_length(): """Test that icons exceeding 63 bytes are rejected.""" # Exactly 63 bytes should pass max_icon = "mdi:" + "a" * 59 # 63 bytes total - assert config_validation.icon(max_icon) == max_icon + assert cv.icon(max_icon) == max_icon # 64 bytes should fail too_long = "mdi:" + "a" * 60 # 64 bytes total with pytest.raises(Invalid, match="Icon string is too long"): - config_validation.icon(too_long) + cv.icon(too_long) def test_byte_length() -> None: """Test ByteLength validator checks UTF-8 byte length, not char count.""" - validator = config_validation.ByteLength(max=10) # pylint: disable=no-member + validator = cv.ByteLength(max=10) # pylint: disable=no-member # ASCII: 10 chars = 10 bytes, should pass assert validator("a" * 10) == "a" * 10 @@ -262,46 +370,51 @@ def test_byte_length() -> None: @pytest.mark.parametrize("value", ("True", "YES", "on", "enAblE", True)) def test_boolean__valid_true(value): - assert config_validation.boolean(value) is True + assert cv.boolean(value) is True @pytest.mark.parametrize("value", ("False", "NO", "off", "disAblE", False)) def test_boolean__valid_false(value): - assert config_validation.boolean(value) is False + assert cv.boolean(value) is False @pytest.mark.parametrize("value", (None, 1, 0, "foo")) def test_boolean__invalid(value): with pytest.raises(Invalid, match="Expected boolean value"): - config_validation.boolean(value) + cv.boolean(value) +# deadline disabled: the validator is trivially fast, but Hypothesis's per-example +# deadline can spuriously trip on slow/loaded CI runners (e.g. one example hitting +# a GC pause), making this a flaky failure. Matches test_helpers.py. +@settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_ipv4__valid(value): - config_validation.ipv4address(value) + cv.ipv4address(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) def test_ipv4__invalid(value): with pytest.raises(Invalid, match="is not a valid IPv4 address"): - config_validation.ipv4address(value) + cv.ipv4address(value) +@settings(deadline=None) @given(value=ip_addresses(v=6).map(str)) def test_ipv6__valid(value): - config_validation.ipaddress(value) + cv.ipaddress(value) @pytest.mark.parametrize("value", ("127.0.0", "localhost", "", "2001:db8::2::3")) def test_ipv6__invalid(value): with pytest.raises(Invalid, match="is not a valid IP address"): - config_validation.ipaddress(value) + cv.ipaddress(value) # TODO: ensure_list @given(integers()) def hex_int__valid(value): - actual = config_validation.hex_int(value) + actual = cv.hex_int(value) assert isinstance(actual, HexInt) assert actual == value @@ -325,7 +438,7 @@ def hex_int__valid(value): ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32C6, "16", "16", "14", "14"), ("arduino", PLATFORM_ESP32, VARIANT_ESP32H2, "18", "17", "18", "17"), ("esp-idf", PLATFORM_ESP32, VARIANT_ESP32H2, "19", "19", "17", "17"), - ("arduino", PLATFORM_RP2040, None, "20", "20", "20", "20"), + ("arduino", PLATFORM_RP2, None, "20", "20", "20", "20"), ("arduino", PLATFORM_BK72XX, None, "21", "21", "21", "21"), ("arduino", PLATFORM_RTL87XX, None, "22", "22", "22", "22"), ("arduino", PLATFORM_LN882X, None, "23", "23", "23", "23"), @@ -356,7 +469,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_c3": "11", "esp32_c6": "14", "esp32_h2": "17", - "rp2040": "20", + "rp2": "20", "bk72xx": "21", "rtl87xx": "22", "ln882x": "23", @@ -381,18 +494,14 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_h2_idf": "19", } - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.SplitDefault( + cv.SplitDefault( "full", **common_mappings, **idf_mappings, **arduino_mappings ): str, - config_validation.SplitDefault( - "idf", **common_mappings, **idf_mappings - ): str, - config_validation.SplitDefault( - "arduino", **common_mappings, **arduino_mappings - ): str, - config_validation.SplitDefault("simple", **common_mappings): str, + cv.SplitDefault("idf", **common_mappings, **idf_mappings): str, + cv.SplitDefault("arduino", **common_mappings, **arduino_mappings): str, + cv.SplitDefault("simple", **common_mappings): str, } ) @@ -408,7 +517,7 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), - ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), + ("arduino", PLATFORM_RP2, "RP2 using arduino framework"), ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), ("host", PLATFORM_HOST, "HOST using host framework"), ], @@ -424,16 +533,16 @@ def test_require_framework_version(framework, platform, message): CORE.data[KEY_CORE] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = framework - CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = config_validation.Version(1, 0, 0) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = cv.Version(1, 0, 0) assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), extra_message="test 1", )("test") == "test" @@ -443,24 +552,24 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires at least framework version 2.0.0. test 2", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(2, 0, 0), - esp32_arduino=config_validation.Version(2, 0, 0), - esp8266_arduino=config_validation.Version(2, 0, 0), - rp2040_arduino=config_validation.Version(2, 0, 0), - bk72xx_arduino=config_validation.Version(2, 0, 0), - host=config_validation.Version(2, 0, 0), + cv.require_framework_version( + esp_idf=cv.Version(2, 0, 0), + esp32_arduino=cv.Version(2, 0, 0), + esp8266_arduino=cv.Version(2, 0, 0), + rp2_arduino=cv.Version(2, 0, 0), + bk72xx_arduino=cv.Version(2, 0, 0), + host=cv.Version(2, 0, 0), extra_message="test 2", )("test") assert ( - config_validation.require_framework_version( - esp_idf=config_validation.Version(1, 5, 0), - esp32_arduino=config_validation.Version(1, 5, 0), - esp8266_arduino=config_validation.Version(1, 5, 0), - rp2040_arduino=config_validation.Version(1, 5, 0), - bk72xx_arduino=config_validation.Version(1, 5, 0), - host=config_validation.Version(1, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(1, 5, 0), + esp32_arduino=cv.Version(1, 5, 0), + esp8266_arduino=cv.Version(1, 5, 0), + rp2_arduino=cv.Version(1, 5, 0), + bk72xx_arduino=cv.Version(1, 5, 0), + host=cv.Version(1, 5, 0), max_version=True, extra_message="test 3", )("test") @@ -471,13 +580,13 @@ def test_require_framework_version(framework, platform, message): vol.error.Invalid, match="This feature requires framework version 0.5.0 or lower. test 4", ): - config_validation.require_framework_version( - esp_idf=config_validation.Version(0, 5, 0), - esp32_arduino=config_validation.Version(0, 5, 0), - esp8266_arduino=config_validation.Version(0, 5, 0), - rp2040_arduino=config_validation.Version(0, 5, 0), - bk72xx_arduino=config_validation.Version(0, 5, 0), - host=config_validation.Version(0, 5, 0), + cv.require_framework_version( + esp_idf=cv.Version(0, 5, 0), + esp32_arduino=cv.Version(0, 5, 0), + esp8266_arduino=cv.Version(0, 5, 0), + rp2_arduino=cv.Version(0, 5, 0), + bk72xx_arduino=cv.Version(0, 5, 0), + host=cv.Version(0, 5, 0), max_version=True, extra_message="test 4", )("test") @@ -485,18 +594,206 @@ def test_require_framework_version(framework, platform, message): with pytest.raises( vol.error.Invalid, match=f"This feature is incompatible with {message}. test 5" ): - config_validation.require_framework_version( + cv.require_framework_version( extra_message="test 5", )("test") +def _setup_core_for_framework(platform: str, framework: str) -> None: + """Wire CORE.data with the minimum keys for require_framework_version / + SplitDefault to evaluate without raising KeyError.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + KEY_FRAMEWORK_VERSION: cv.Version(1, 0, 0), + } + + +def test_only_on_rp2_passes_on_rp2_platform() -> None: + """``cv.only_on_rp2`` is the canonical family gate. It accepts any value + untouched when the configured platform is rp2.""" + _setup_core_for_framework(PLATFORM_RP2, "arduino") + assert cv.only_on_rp2("anything") == "anything" + + +def test_only_on_rp2_rejects_other_platforms() -> None: + """The same gate raises ``Invalid`` outside the rp2 platform.""" + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + with pytest.raises(Invalid, match="rp2"): + cv.only_on_rp2("anything") + + +def test_only_on_rp2040_delegates_and_warns_once(caplog) -> None: + """``cv.only_on_rp2040`` is a deprecation shim — it logs a one-shot + warning, dedupes via CORE.data, and delegates to ``only_on_rp2``. + Repeated calls in the same run must not log again.""" + import logging + + _setup_core_for_framework(PLATFORM_RP2, "arduino") + # Reset the dedupe flag so this test is independent of order. + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with caplog.at_level(logging.WARNING, logger="esphome.config_validation"): + assert cv.only_on_rp2040("ok") == "ok" + first_warnings = [r for r in caplog.records if "only_on_rp2040" in r.message] + assert len(first_warnings) == 1 + assert "2027.7.0" in first_warnings[0].message + + # Second call dedupes — no additional warning is emitted. + assert cv.only_on_rp2040("ok") == "ok" + warnings_after_second = [ + r for r in caplog.records if "only_on_rp2040" in r.message + ] + assert len(warnings_after_second) == 1 + + +def test_only_on_rp2040_still_gates_on_non_rp2(caplog) -> None: + """The deprecation shim must still raise on non-rp2 platforms — it + delegates to ``only_on_rp2``, so the gating behavior is preserved.""" + import logging + + _setup_core_for_framework(PLATFORM_ESP32, "arduino") + CORE.data.pop(cv._ONLY_ON_RP2040_DEPRECATED_KEY, None) + + with ( + caplog.at_level(logging.WARNING, logger="esphome.config_validation"), + pytest.raises(Invalid, match="rp2"), + ): + cv.only_on_rp2040("anything") + + +def test_require_framework_version_esp32_variant_specific_key() -> None: + """ESP32 variant-specific kwargs (``esp32_c3_arduino``) must win over + the base ``esp32_arduino`` key when the configured variant matches.""" + from esphome.components.esp32 import KEY_ESP32 + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_ESP32, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32C3} + + # Variant-specific entry permits this version; base key would reject it. + assert ( + cv.require_framework_version( + esp32_arduino=cv.Version(5, 0, 0), # would reject + esp32_c3_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + +def test_require_framework_version_rp2_variant_specific_key() -> None: + """RP2 variant kwargs (``rp2_2040_arduino``) must win over the base + ``rp2_arduino`` key when ``CORE.data['rp2']['variant']`` is wired.""" + from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + ) + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + KEY_FRAMEWORK_VERSION: cv.Version(1, 2, 0), + } + CORE.data["rp2"] = {"variant": "RP2040"} + + # Variant key wins — base ``rp2_arduino`` (which would reject) is ignored. + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(5, 0, 0), # would reject + rp2_2040_arduino=cv.Version(1, 0, 0), # wins, ok + )("test") + == "test" + ) + + # Without a variant kwarg the base ``rp2_arduino`` is used (fallback). + CORE.data["rp2"] = {"variant": "RP2350"} + assert ( + cv.require_framework_version( + rp2_arduino=cv.Version(1, 0, 0), + )("test") + == "test" + ) + + +def test_split_default_rp2_variant_keys() -> None: + """``SplitDefault`` resolves ``rp2__`` first, falling + back to ``rp2_`` and ``rp2_`` before the base key.""" + from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_RP2, + KEY_TARGET_FRAMEWORK: "arduino", + } + CORE.data["rp2"] = {"variant": "RP2040"} + + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + rp2_2040_arduino="variant-framework", + ): str, + } + ) + # Most specific (variant + framework) wins. + assert schema({}).get("full") == "variant-framework" + + # Drop the most-specific kwarg → variant-only wins. + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="variant-only", + ): str, + } + ) + assert schema({}).get("full") == "variant-only" + + # RP2350 variant — no rp2_2350_* kwargs → fall through to base framework. + CORE.data["rp2"] = {"variant": "RP2350"} + schema = cv.Schema( + { + cv.SplitDefault( + "full", + rp2="base", + rp2_arduino="base-framework", + rp2_2040="not-this", + ): str, + } + ) + assert schema({}).get("full") == "base-framework" + + def test_only_with_single_component_loaded() -> None: """Test OnlyWith with single component when component is loaded.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -508,9 +805,9 @@ def test_only_with_single_component_not_loaded() -> None: """Test OnlyWith with single component when component is not loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="test_mqtt"): str, } ) @@ -522,11 +819,9 @@ def test_only_with_list_all_components_loaded() -> None: """Test OnlyWith with list when all components are loaded.""" CORE.loaded_integrations = {"zigbee", "nrf52"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -538,11 +833,9 @@ def test_only_with_list_partial_components_loaded() -> None: """Test OnlyWith with list when only some components are loaded.""" CORE.loaded_integrations = {"zigbee"} # Only zigbee, not nrf52 - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -554,11 +847,9 @@ def test_only_with_list_no_components_loaded() -> None: """Test OnlyWith with list when no components are loaded.""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( - "zigbee_id", ["zigbee", "nrf52"], default="test_zigbee" - ): str, + cv.OnlyWith("zigbee_id", ["zigbee", "nrf52"], default="test_zigbee"): str, } ) @@ -570,9 +861,9 @@ def test_only_with_list_multiple_components() -> None: """Test OnlyWith with list requiring three components.""" CORE.loaded_integrations = {"comp1", "comp2", "comp3"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith( + cv.OnlyWith( "test_id", ["comp1", "comp2", "comp3"], default="test_value" ): str, } @@ -591,9 +882,9 @@ def test_only_with_empty_list() -> None: """Test OnlyWith with empty list (edge case).""" CORE.loaded_integrations = set() - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("test_id", [], default="test_value"): str, + cv.OnlyWith("test_id", [], default="test_value"): str, } ) @@ -606,9 +897,9 @@ def test_only_with_user_value_overrides_default() -> None: """Test OnlyWith respects user-provided values over defaults.""" CORE.loaded_integrations = {"mqtt"} - schema = config_validation.Schema( + schema = cv.Schema( { - config_validation.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, + cv.OnlyWith("mqtt_id", "mqtt", default="default_id"): str, } ) @@ -618,7 +909,7 @@ def test_only_with_user_value_overrides_default() -> None: @pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度")) def test_string_no_slash__valid(value: str) -> None: - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == value @@ -635,7 +926,7 @@ def test_string_no_slash__slash_replaced_with_warning( value: str, expected: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that '/' is auto-replaced with fraction slash and warning is logged.""" - actual = config_validation.string_no_slash(value) + actual = cv.string_no_slash(value) assert actual == expected assert "reserved as a URL path separator" in caplog.text assert "will become an error in ESPHome 2026.7.0" in caplog.text @@ -644,16 +935,16 @@ def test_string_no_slash__slash_replaced_with_warning( def test_string_no_slash__long_string_allowed() -> None: # string_no_slash doesn't enforce length - use cv.Length() separately long_value = "x" * 200 - assert config_validation.string_no_slash(long_value) == long_value + assert cv.string_no_slash(long_value) == long_value def test_string_no_slash__empty() -> None: - assert config_validation.string_no_slash("") == "" + assert cv.string_no_slash("") == "" @pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器")) def test_validate_entity_name__valid(value: str) -> None: - actual = config_validation._validate_entity_name(value) + actual = cv._validate_entity_name(value) assert actual == value @@ -661,40 +952,40 @@ def test_validate_entity_name__slash_replaced_with_warning( caplog: pytest.LogCaptureFixture, ) -> None: """Test that '/' in entity names is auto-replaced with fraction slash.""" - actual = config_validation._validate_entity_name("has/slash") + actual = cv._validate_entity_name("has/slash") assert actual == "has⁄slash" assert "reserved as a URL path separator" in caplog.text def test_validate_entity_name__max_length() -> None: # 120 bytes should pass - assert config_validation._validate_entity_name("x" * 120) == "x" * 120 + assert cv._validate_entity_name("x" * 120) == "x" * 120 # 121 bytes should fail with pytest.raises(Invalid, match="too long.*121 bytes.*Maximum.*120"): - config_validation._validate_entity_name("x" * 121) + cv._validate_entity_name("x" * 121) def test_validate_entity_name__multibyte_byte_length() -> None: # 40 chars of 3-byte UTF-8 = 120 bytes, should pass - assert config_validation._validate_entity_name("温" * 40) == "温" * 40 + assert cv._validate_entity_name("温" * 40) == "温" * 40 # 41 chars of 3-byte UTF-8 = 123 bytes, should fail (over 120 byte limit) with pytest.raises(Invalid, match="too long.*123 bytes.*Maximum.*120"): - config_validation._validate_entity_name("温" * 41) + cv._validate_entity_name("温" * 41) def test_validate_entity_name__none_without_friendly_name() -> None: # When name is "None" and friendly_name is not set, it should fail CORE.friendly_name = None with pytest.raises(Invalid, match="friendly_name is not set"): - config_validation._validate_entity_name("None") + cv._validate_entity_name("None") def test_validate_entity_name__none_with_friendly_name() -> None: # When name is "None" but friendly_name is set, it should return None CORE.friendly_name = "My Device" - result = config_validation._validate_entity_name("None") + result = cv._validate_entity_name("None") assert result is None CORE.friendly_name = None # Reset @@ -717,7 +1008,7 @@ def test_validate_entity_name__none_with_friendly_name() -> None: ), ) def test_percentage__valid(value: object, expected: float) -> None: - assert config_validation.percentage(value) == expected + assert cv.percentage(value) == expected @pytest.mark.parametrize( @@ -735,7 +1026,7 @@ def test_percentage__valid(value: object, expected: float) -> None: ) def test_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.percentage(value) + cv.percentage(value) @pytest.mark.parametrize( @@ -754,7 +1045,7 @@ def test_percentage__invalid(value: object) -> None: ), ) def test_possibly_negative_percentage__valid(value: object, expected: float) -> None: - assert config_validation.possibly_negative_percentage(value) == expected + assert cv.possibly_negative_percentage(value) == expected @pytest.mark.parametrize( @@ -770,7 +1061,7 @@ def test_possibly_negative_percentage__valid(value: object, expected: float) -> ) def test_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.possibly_negative_percentage(value) + cv.possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -787,7 +1078,7 @@ def test_possibly_negative_percentage__invalid(value: object) -> None: ), ) def test_unbounded_percentage__valid(value: object, expected: float) -> None: - assert config_validation.unbounded_percentage(value) == expected + assert cv.unbounded_percentage(value) == expected @pytest.mark.parametrize( @@ -802,7 +1093,7 @@ def test_unbounded_percentage__valid(value: object, expected: float) -> None: ) def test_unbounded_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) @pytest.mark.parametrize( @@ -825,13 +1116,13 @@ def test_unbounded_percentage__invalid(value: object) -> None: def test_unbounded_possibly_negative_percentage__valid( value: object, expected: float ) -> None: - assert config_validation.unbounded_possibly_negative_percentage(value) == expected + assert cv.unbounded_possibly_negative_percentage(value) == expected @pytest.mark.parametrize("value", ("foo", None)) def test_unbounded_possibly_negative_percentage__invalid(value: object) -> None: with pytest.raises(Invalid): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) @pytest.mark.parametrize( @@ -843,9 +1134,9 @@ def test_percentage_validators__raw_number_above_one_without_percent_sign( ) -> None: """Raw numeric values outside [-1, 1] must use a percent sign.""" with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_percentage(value) + cv.unbounded_percentage(value) with pytest.raises(Invalid, match="percent sign"): - config_validation.unbounded_possibly_negative_percentage(value) + cv.unbounded_possibly_negative_percentage(value) def test_update_interval__coerces_zero_to_one_ms( @@ -856,7 +1147,7 @@ def test_update_interval__coerces_zero_to_one_ms( existing configs compiling on upgrade while emitting a user-facing warning that directs them to set a non-zero value.""" with caplog.at_level("WARNING"): - result = config_validation.update_interval("0ms") + result = cv.update_interval("0ms") assert result.total_milliseconds == 1 assert "update_interval of 0ms is not supported" in caplog.text assert "1ms" in caplog.text @@ -864,14 +1155,14 @@ def test_update_interval__coerces_zero_to_one_ms( def test_update_interval__preserves_nonzero_values() -> None: """Non-zero update_interval values must pass through unchanged.""" - assert config_validation.update_interval("1ms").total_milliseconds == 1 - assert config_validation.update_interval("50ms").total_milliseconds == 50 - assert config_validation.update_interval("60s").total_milliseconds == 60000 + assert cv.update_interval("1ms").total_milliseconds == 1 + assert cv.update_interval("50ms").total_milliseconds == 50 + assert cv.update_interval("60s").total_milliseconds == 60000 def test_update_interval__never_passes_through() -> None: """update_interval: never must still map to SCHEDULER_DONT_RUN.""" - result = config_validation.update_interval("never") + result = cv.update_interval("never") assert result.total_milliseconds == SCHEDULER_DONT_RUN @@ -883,28 +1174,36 @@ def test_update_interval__never_passes_through() -> None: def test_optional_default_visibility_is_none() -> None: """An ``Optional`` with no ``visibility`` kwarg reports ``None``. - Consumers can read the attribute directly with plain attribute - access; absence (``None``) means "render on the editor's main - form." + The marker stays faithful to what the author wrote: ESPHome does + not encode the default on it. Resolving ``None`` to an effective + visibility is the consumer's job — a schema-aware editor treats an + unset ``Optional`` as ``ADVANCED`` (see :class:`Visibility`). """ - o = config_validation.Optional("foo") + o = cv.Optional("foo") assert o.visibility is None def test_optional_visibility_advanced() -> None: """``visibility=Visibility.ADVANCED`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert o.visibility is config_validation.Visibility.ADVANCED + o = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) + assert o.visibility is cv.Visibility.ADVANCED def test_optional_visibility_yaml_only() -> None: """``visibility=Visibility.YAML_ONLY`` is recorded on the marker.""" - o = config_validation.Optional( - "foo", visibility=config_validation.Visibility.YAML_ONLY - ) - assert o.visibility is config_validation.Visibility.YAML_ONLY + o = cv.Optional("foo", visibility=cv.Visibility.YAML_ONLY) + assert o.visibility is cv.Visibility.YAML_ONLY + + +def test_optional_visibility_ui() -> None: + """``visibility=Visibility.UI`` is recorded on the marker. + + ``UI`` promotes an ``Optional`` onto the editor's main form, + overriding the consumer's default of ``ADVANCED`` for unset + optionals. + """ + o = cv.Optional("foo", visibility=cv.Visibility.UI) + assert o.visibility is cv.Visibility.UI def test_visibility_str_values_match_dump_emission() -> None: @@ -916,8 +1215,9 @@ def test_visibility_str_values_match_dump_emission() -> None: field — pinning the on-the-wire spelling here keeps the dump contract stable. """ - assert str(config_validation.Visibility.ADVANCED) == "advanced" - assert str(config_validation.Visibility.YAML_ONLY) == "yaml_only" + assert str(cv.Visibility.UI) == "ui" + assert str(cv.Visibility.ADVANCED) == "advanced" + assert str(cv.Visibility.YAML_ONLY) == "yaml_only" def test_optional_visibility_does_not_affect_validation() -> None: @@ -925,16 +1225,14 @@ def test_optional_visibility_does_not_affect_validation() -> None: validator behaves. A schema with ``visibility`` applied must accept and reject the same values it would without it. """ - plain = config_validation.Schema( - {config_validation.Optional("foo", default=42): config_validation.int_} - ) - flagged = config_validation.Schema( + plain = cv.Schema({cv.Optional("foo", default=42): cv.int_}) + flagged = cv.Schema( { - config_validation.Optional( + cv.Optional( "foo", default=42, - visibility=config_validation.Visibility.YAML_ONLY, - ): config_validation.int_ + visibility=cv.Visibility.YAML_ONLY, + ): cv.int_ } ) # Same accept / default-fill behavior. @@ -949,7 +1247,7 @@ def test_optional_visibility_does_not_affect_validation() -> None: def test_required_default_visibility_is_none() -> None: """``Required`` mirrors ``Optional`` for the ``visibility`` kwarg.""" - r = config_validation.Required("foo") + r = cv.Required("foo") assert r.visibility is None @@ -959,10 +1257,8 @@ def test_required_visibility_kwarg() -> None: Required fields rarely need the kwarg, but exposing it lets consumers apply uniform logic across key markers. """ - r = config_validation.Required( - "foo", visibility=config_validation.Visibility.ADVANCED - ) - assert r.visibility is config_validation.Visibility.ADVANCED + r = cv.Required("foo", visibility=cv.Visibility.ADVANCED) + assert r.visibility is cv.Visibility.ADVANCED def test_polling_component_schema_visibility_opt_in() -> None: @@ -971,28 +1267,17 @@ def test_polling_component_schema_visibility_opt_in() -> None: Time platforms pass ``Visibility.ADVANCED``; sensors and other polling components leave it ``None`` and keep the un-flagged shape. """ - default = config_validation.polling_component_schema("15min") - advanced = config_validation.polling_component_schema( - "15min", visibility=config_validation.Visibility.ADVANCED - ) + default = cv.polling_component_schema("15min") + advanced = cv.polling_component_schema("15min", visibility=cv.Visibility.ADVANCED) default_keys = {str(k): k for k in default.schema} advanced_keys = {str(k): k for k in advanced.schema} assert default_keys["update_interval"].visibility is None - assert ( - advanced_keys["update_interval"].visibility - is config_validation.Visibility.ADVANCED - ) + assert advanced_keys["update_interval"].visibility is cv.Visibility.ADVANCED # The opt-in only touches update_interval — setup_priority # still inherits its YAML_ONLY visibility from COMPONENT_SCHEMA # in both shapes. - assert ( - default_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) - assert ( - advanced_keys["setup_priority"].visibility - is config_validation.Visibility.YAML_ONLY - ) + assert default_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY + assert advanced_keys["setup_priority"].visibility is cv.Visibility.YAML_ONLY def test_polling_component_schema_no_default_ignores_visibility() -> None: @@ -1005,11 +1290,9 @@ def test_polling_component_schema_no_default_ignores_visibility() -> None: required field. The helper accepts the kwarg unconditionally for caller ergonomics but doesn't honour it on this branch. """ - schema = config_validation.polling_component_schema( - None, visibility=config_validation.Visibility.ADVANCED - ) + schema = cv.polling_component_schema(None, visibility=cv.Visibility.ADVANCED) keys = {str(k): k for k in schema.schema} - assert isinstance(keys["update_interval"], config_validation.Required) + assert isinstance(keys["update_interval"], cv.Required) assert keys["update_interval"].visibility is None @@ -1032,28 +1315,1568 @@ def test_visibility_marker_is_per_field_no_mutation() -> None: detail this test deliberately doesn't pin, since it's a consumer concern). """ - inner_unset = config_validation.Optional("baz") - inner_yaml_only = config_validation.Optional( - "qux", visibility=config_validation.Visibility.YAML_ONLY - ) - parent = config_validation.Optional( - "foo", visibility=config_validation.Visibility.ADVANCED - ) + inner_unset = cv.Optional("baz") + inner_yaml_only = cv.Optional("qux", visibility=cv.Visibility.YAML_ONLY) + parent = cv.Optional("foo", visibility=cv.Visibility.ADVANCED) # Wire them into a nested schema — none of the markers' own # ``visibility`` should change as a result. - schema = config_validation.Schema( + schema = cv.Schema( { - parent: config_validation.Schema( + parent: cv.Schema( { - inner_unset: config_validation.int_, - inner_yaml_only: config_validation.string, + inner_unset: cv.int_, + inner_yaml_only: cv.string, } ) } ) assert schema # touch the schema so any deferred mutation runs - assert parent.visibility is config_validation.Visibility.ADVANCED + assert parent.visibility is cv.Visibility.ADVANCED assert inner_unset.visibility is None - assert inner_yaml_only.visibility is config_validation.Visibility.YAML_ONLY + assert inner_yaml_only.visibility is cv.Visibility.YAML_ONLY + + +def test_entity_metadata_visibility_hints() -> None: + """Entity and value-describing metadata is classified for visual editors. + + The headline ``name`` stays on the main form (``UI``); descriptive + metadata (device_class, unit, …), presentation options, and per-entity + integration plumbing (MQTT, web_server ordering) fall to the advanced + disclosure (``ADVANCED``). + """ + advanced = cv.Visibility.ADVANCED + + entity_base = {str(k): k for k in cv.ENTITY_BASE_SCHEMA.schema} + assert entity_base["name"].visibility is cv.Visibility.UI + for field in ( + "icon", + "internal", + "disabled_by_default", + "entity_category", + "device_id", + ): + assert entity_base[field].visibility is advanced, field + + mqtt = {str(k): k for k in cv.MQTT_COMPONENT_SCHEMA.schema} + for field in ("qos", "retain", "discovery", "state_topic", "availability"): + assert mqtt[field].visibility is advanced, field + + from esphome.components import binary_sensor, number, sensor + from esphome.components.web_server import WEBSERVER_SORTING_SCHEMA + + sensor_markers = {str(k): k for k in sensor.sensor_schema().schema} + for field in ( + "unit_of_measurement", + "accuracy_decimals", + "device_class", + "state_class", + "force_update", + ): + assert sensor_markers[field].visibility is advanced, field + + binary = {str(k): k for k in binary_sensor.binary_sensor_schema().schema} + assert binary["device_class"].visibility is advanced + + number_markers = {str(k): k for k in number.number_schema(number.Number).schema} + assert number_markers["mode"].visibility is advanced + assert number_markers["device_class"].visibility is advanced + + # The whole per-entity web_server block is advanced; children inherit + # via the consumer cascade, so only the parent key carries the hint. + web = {str(k): k for k in WEBSERVER_SORTING_SCHEMA.schema} + assert web["web_server"].visibility is advanced + + +def _wrap_str(value: str) -> ESPHomeDataBase: + """Wrap a raw string as an ESPHomeDataBase, mimicking a YAML-loaded value.""" + return make_data_base(value) + + +def _set_core_target(platform: str, framework: str) -> None: + """Set CORE target platform/framework for validators that depend on them.""" + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + +def _set_framework_version(platform: str, framework: str, version: cv.Version) -> None: + """Set CORE target platform/framework and framework version.""" + _set_core_target(platform, framework) + CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = version + + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- + + +def test_version_str_with_extra() -> None: + assert str(cv.Version(1, 2, 3, "b1")) == "1.2.3-b1" + + +def test_version_str_without_extra() -> None: + assert str(cv.Version(1, 2, 3)) == "1.2.3" + + +def test_version_parse_valid() -> None: + version = cv.Version.parse("2024.5.1") + assert (version.major, version.minor, version.patch, version.extra) == ( + 2024, + 5, + 1, + "", + ) + + +def test_version_parse_with_extra() -> None: + version = cv.Version.parse("2024.5.1-dev20240101") + assert version.extra == "dev20240101" + + +def test_version_parse_invalid() -> None: + with pytest.raises(ValueError, match="Not a valid version number"): + cv.Version.parse("not.a.version") + + +def test_version_is_beta() -> None: + assert cv.Version.parse("2024.5.0b1").is_beta is True + assert cv.Version.parse("2024.5.0").is_beta is False + + +def test_version_is_dev() -> None: + assert cv.Version.parse("2024.5.0-dev").is_dev is True + assert cv.Version.parse("2024.5.0").is_dev is False + + +# --------------------------------------------------------------------------- +# alphanumeric / valid_name / validate_id_name +# --------------------------------------------------------------------------- + + +def test_alphanumeric_none() -> None: + with pytest.raises(Invalid, match="string value is None"): + cv.alphanumeric(None) + + +def test_valid_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.valid_name("plainname") == "plainname" + + +def test_validate_id_name_empty() -> None: + with pytest.raises(Invalid, match="ID must not be empty"): + cv.validate_id_name("") + + +def test_validate_id_name_digit_first() -> None: + with pytest.raises(Invalid, match="First character in ID cannot be a digit"): + cv.validate_id_name("1abc") + + +def test_validate_id_name_vscode_no_substitution() -> None: + CORE.vscode = True + assert cv.validate_id_name("validid") == "validid" + + +def test_validate_id_name_reserved() -> None: + with pytest.raises(Invalid, match="reserved internally"): + cv.validate_id_name("alarm") + + +def test_validate_id_name_integration_conflict() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises( + Invalid, match="conflicts with the name of an esphome integration" + ): + cv.validate_id_name("mqtt") + + +# --------------------------------------------------------------------------- +# sub_device_id +# --------------------------------------------------------------------------- + + +def test_sub_device_id_schema_extract() -> None: + from esphome.core.config import Device + + assert cv.sub_device_id(SCHEMA_EXTRACT) is Device + + +def test_sub_device_id_empty() -> None: + assert cv.sub_device_id(None) is None + assert cv.sub_device_id("") is None + + +def test_sub_device_id_valid() -> None: + result = cv.sub_device_id("my_device") + assert isinstance(result, ID) + assert result.id == "my_device" + + +# --------------------------------------------------------------------------- +# boolean_false / ensure_list +# --------------------------------------------------------------------------- + + +def test_boolean_false_valid() -> None: + assert cv.boolean_false(False) is False + assert cv.boolean_false("no") is False + + +def test_boolean_false_invalid() -> None: + with pytest.raises(Invalid, match="Expected boolean value to be false"): + cv.boolean_false(True) + + +def test_ensure_list_none() -> None: + assert cv.ensure_list(cv.int_)(None) == [] + + +def test_ensure_list_empty_dict() -> None: + assert cv.ensure_list(cv.int_)({}) == [] + + +def test_ensure_list_single_value() -> None: + assert cv.ensure_list(cv.int_)(5) == [5] + + +def test_ensure_list_actual_list() -> None: + assert cv.ensure_list(cv.int_)([1, 2, 3]) == [1, 2, 3] + + +# --------------------------------------------------------------------------- +# hex_int / int_to_hex_string / int_ +# --------------------------------------------------------------------------- + + +def test_hex_int() -> None: + result = cv.hex_int(255) + assert result == 255 + assert isinstance(result, HexInt) + + +def test_int_to_hex_string_int() -> None: + assert cv.int_to_hex_string(64) == "0x40" + + +def test_int_to_hex_string_passthrough() -> None: + assert cv.int_to_hex_string("already") == "already" + + +def test_int_float_whole() -> None: + assert cv.int_(5.0) == 5 + + +def test_int_float_fractional() -> None: + with pytest.raises(Invalid, match="only accepts integers with no fractional part"): + cv.int_(5.5) + + +def test_int_hex_string() -> None: + assert cv.int_("0xFF") == 255 + + +# --------------------------------------------------------------------------- +# int_range / float_range no-min branches +# --------------------------------------------------------------------------- + + +def test_int_range_no_min() -> None: + validator = cv.int_range(max=10) + assert validator(5) == 5 + + +def test_float_range_no_min() -> None: + validator = cv.float_range(max=10.0) + assert validator(5.0) == 5.0 + + +# --------------------------------------------------------------------------- +# use_id / declare_id / templatable +# --------------------------------------------------------------------------- + + +def test_use_id_schema_extract() -> None: + assert cv.use_id(int)(SCHEMA_EXTRACT) is int + + +def test_use_id_none() -> None: + result = cv.use_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is False + + +def test_use_id_existing_id_passthrough() -> None: + existing = ID("foo", is_declaration=False, type=int) + assert cv.use_id(int)(existing) is existing + + +def test_use_id_from_string() -> None: + result = cv.use_id(int)("foo") + assert isinstance(result, ID) + assert result.id == "foo" + assert result.is_declaration is False + + +def test_declare_id_schema_extract() -> None: + assert cv.declare_id(int)(SCHEMA_EXTRACT) is int + + +def test_declare_id_none() -> None: + result = cv.declare_id(int)(None) + assert isinstance(result, ID) + assert result.is_declaration is True + + +def test_declare_id_from_string() -> None: + result = cv.declare_id(int)("foo") + assert result.id == "foo" + assert result.is_declaration is True + + +def test_templatable_schema_extract() -> None: + assert cv.templatable(cv.int_)(SCHEMA_EXTRACT) is cv.int_ + + +def test_templatable_lambda() -> None: + result = cv.templatable(cv.int_)(Lambda("return 5;")) + assert isinstance(result, Lambda) + + +def test_templatable_plain_value() -> None: + assert cv.templatable(cv.int_)(5) == 5 + + +def test_templatable_dict_validators() -> None: + validator = cv.templatable({cv.Required("x"): cv.int_}) + assert validator({"x": 5}) == {"x": 5} + + +# --------------------------------------------------------------------------- +# only_on / only_with_framework +# --------------------------------------------------------------------------- + + +def test_only_on_list_platform_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]) + assert validator("x") == "x" + + +def test_only_on_wrong_platform() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + validator = cv.only_on(PLATFORM_ESP32) + with pytest.raises(Invalid, match="only available on"): + validator("x") + + +def test_only_with_framework_match() -> None: + _set_core_target(PLATFORM_ESP32, "arduino") + validator = cv.only_with_framework([Framework.ARDUINO]) + assert validator("x") == "x" + + +def test_only_with_framework_mismatch_with_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", "some/path")}, + ) + with pytest.raises(Invalid, match="some/path"): + validator("x") + + +def test_only_with_framework_mismatch_no_suggestion() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework(Framework.ARDUINO) + with pytest.raises(Invalid, match="only available with framework"): + validator("x") + + +def test_only_with_framework_suggestion_without_docs_path() -> None: + _set_core_target(PLATFORM_ESP32, "esp-idf") + validator = cv.only_with_framework( + Framework.ARDUINO, + suggestions={Framework.ESP_IDF: ("some_component", None)}, + ) + with pytest.raises(Invalid, match="Please use 'some_component'"): + validator("x") + + +# --------------------------------------------------------------------------- +# has_*_key helpers +# --------------------------------------------------------------------------- + + +def test_has_at_least_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_least_one_key("a", "b")([]) + + +def test_has_at_least_one_key_none() -> None: + with pytest.raises(Invalid, match="at least one of"): + cv.has_at_least_one_key("a", "b")({"c": 1}) + + +def test_has_at_least_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_least_one_key("a", "b")(obj) is obj + + +def test_has_exactly_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_exactly_one_key("a", "b")("notdict") + + +def test_has_exactly_one_key_too_many() -> None: + with pytest.raises(Invalid, match="Cannot specify more than one"): + cv.has_exactly_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_exactly_one_key_too_few() -> None: + with pytest.raises(Invalid, match="Must contain exactly one"): + cv.has_exactly_one_key("a", "b")({"c": 1}) + + +def test_has_exactly_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_exactly_one_key("a", "b")(obj) is obj + + +def test_has_at_most_one_key_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_at_most_one_key("a", "b")(5) + + +def test_has_at_most_one_key_too_many() -> None: + with pytest.raises(vol.MultipleInvalid, match="Cannot specify more than one"): + cv.has_at_most_one_key("a", "b")({"a": 1, "b": 2}) + + +def test_has_at_most_one_key_ok() -> None: + obj = {"a": 1} + assert cv.has_at_most_one_key("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_not_dict() -> None: + with pytest.raises(Invalid, match="expected dictionary"): + cv.has_none_or_all_keys("a", "b")(5) + + +def test_has_none_or_all_keys_partial() -> None: + with pytest.raises(Invalid, match="none or all"): + cv.has_none_or_all_keys("a", "b")({"a": 1}) + + +def test_has_none_or_all_keys_all() -> None: + obj = {"a": 1, "b": 2} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +def test_has_none_or_all_keys_none() -> None: + obj = {"c": 3} + assert cv.has_none_or_all_keys("a", "b")(obj) is obj + + +# --------------------------------------------------------------------------- +# time_period_str_colon / time_period_str_unit +# --------------------------------------------------------------------------- + + +def test_time_period_str_colon_int() -> None: + with pytest.raises(Invalid, match="wrap time values in quotes"): + cv.time_period_str_colon(5) + + +def test_time_period_str_colon_not_str() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon([1, 2]) + + +def test_time_period_str_colon_bad_value() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("aa:bb") + + +def test_time_period_str_colon_hh_mm() -> None: + assert cv.time_period_str_colon("01:30") == TimePeriod(hours=1, minutes=30) + + +def test_time_period_str_colon_hh_mm_ss() -> None: + assert cv.time_period_str_colon("01:30:15") == TimePeriod( + hours=1, minutes=30, seconds=15 + ) + + +def test_time_period_str_colon_too_many_parts() -> None: + with pytest.raises(Invalid): + cv.time_period_str_colon("1:2:3:4") + + +def test_time_period_str_unit_int() -> None: + with pytest.raises(Invalid, match=r"no time \*unit\*"): + cv.time_period_str_unit(5) + + +def test_time_period_str_unit_timeperiod_input() -> None: + assert cv.time_period_str_unit(TimePeriod(seconds=5)) == TimePeriod(seconds=5) + + +def test_time_period_str_unit_not_str() -> None: + with pytest.raises(Invalid, match="Expected string for time period"): + cv.time_period_str_unit([1]) + + +def test_time_period_str_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected time period with unit"): + cv.time_period_str_unit("5/3") + + +def test_time_period_str_unit_empty_mantissa() -> None: + with pytest.raises(Invalid): + cv.time_period_str_unit("s") + + +# --------------------------------------------------------------------------- +# time_period_in_* converters +# --------------------------------------------------------------------------- + + +def test_time_period_in_milliseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is milliseconds"): + cv.time_period_in_milliseconds_(TimePeriod(microseconds=5)) + + +def test_time_period_in_microseconds_too_precise() -> None: + with pytest.raises(Invalid, match="Maximum precision is microseconds"): + cv.time_period_in_microseconds_(TimePeriod(nanoseconds=5)) + + +def test_time_period_in_microseconds_ok() -> None: + assert cv.time_period_in_microseconds_( + TimePeriod(microseconds=5) + ) == TimePeriodMicroseconds(microseconds=5) + + +def test_time_period_in_nanoseconds_ok() -> None: + assert cv.time_period_in_nanoseconds_( + TimePeriod(nanoseconds=5) + ) == TimePeriodNanoseconds(nanoseconds=5) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + ], +) +def test_time_period_in_seconds_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is seconds"): + cv.time_period_in_seconds_(value) + + +def test_time_period_in_seconds_ok() -> None: + assert cv.time_period_in_seconds_(TimePeriod(seconds=5)) == TimePeriodSeconds( + seconds=5 + ) + + +@pytest.mark.parametrize( + "value", + [ + TimePeriod(nanoseconds=1), + TimePeriod(microseconds=1), + TimePeriod(milliseconds=1), + TimePeriod(seconds=1), + ], +) +def test_time_period_in_minutes_too_precise(value: TimePeriod) -> None: + with pytest.raises(Invalid, match="Maximum precision is minutes"): + cv.time_period_in_minutes_(value) + + +def test_time_period_in_minutes_ok() -> None: + assert cv.time_period_in_minutes_(TimePeriod(minutes=5)) == TimePeriodMinutes( + minutes=5 + ) + + +# --------------------------------------------------------------------------- +# time_of_day / date_time +# --------------------------------------------------------------------------- + + +def test_time_of_day_valid() -> None: + assert cv.time_of_day("12:34:56") == { + CONF_HOUR: 12, + CONF_MINUTE: 34, + CONF_SECOND: 56, + } + + +def test_date_time_dict_input() -> None: + validator = cv.date_time(date=True, time=False) + result = validator({CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1}) + assert result[CONF_YEAR] == 2024 + + +def test_date_time_date_only_string() -> None: + validator = cv.date_time(date=True, time=False) + assert validator("2024-5-1") == {CONF_YEAR: 2024, CONF_MONTH: 5, CONF_DAY: 1} + + +def test_date_time_date_and_time_string() -> None: + validator = cv.date_time(date=True, time=True) + result = validator("2024-05-01 13:30:00") + assert result[CONF_HOUR] == 13 + assert result[CONF_YEAR] == 2024 + + +def test_date_time_invalid_format() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("notatime") + + +def test_date_time_ampm() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("1:30 PM")[CONF_HOUR] == 13 + + +def test_date_time_no_seconds() -> None: + validator = cv.date_time(date=False, time=True) + assert validator("13:30")[CONF_SECOND] == 0 + + +def test_date_time_strptime_error() -> None: + validator = cv.date_time(date=False, time=True) + with pytest.raises(Invalid, match="Invalid time"): + validator("25:99") + + +# --------------------------------------------------------------------------- +# mac_address / uuid +# --------------------------------------------------------------------------- + + +def test_mac_address_valid() -> None: + result = cv.mac_address("AA:BB:CC:DD:EE:FF") + assert isinstance(result, MACAddress) + + +def test_mac_address_wrong_parts() -> None: + with pytest.raises(Invalid, match="6 : .colon. separated parts"): + cv.mac_address("AA:BB:CC") + + +def test_mac_address_wrong_length() -> None: + with pytest.raises(Invalid, match="format XX:XX"): + cv.mac_address("A:BB:CC:DD:EE:FF") + + +def test_mac_address_non_hex() -> None: + with pytest.raises(Invalid, match="hexadecimal values"): + cv.mac_address("GG:BB:CC:DD:EE:FF") + + +def test_uuid_valid() -> None: + result = cv.uuid("12345678-1234-5678-1234-567812345678") + assert str(result) == "12345678-1234-5678-1234-567812345678" + + +# --------------------------------------------------------------------------- +# float_with_unit family +# --------------------------------------------------------------------------- + + +def test_float_with_unit_optional_unit_plain_float() -> None: + assert cv.angle("1.5") == 1.5 + + +def test_float_with_unit_optional_unit_with_suffix() -> None: + assert cv.angle("45deg") == 45.0 + + +def test_float_with_unit_with_suffix() -> None: + assert cv.frequency("10kHz") == 10000.0 + + +def test_float_with_unit_no_match() -> None: + with pytest.raises(Invalid, match="Expected frequency with unit"): + cv.frequency("!!") + + +def test_float_with_unit_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid frequency suffix"): + cv.frequency("10xHz") + + +def test_temperature_celsius() -> None: + assert cv.temperature("25°C") == 25.0 + + +def test_temperature_kelvin() -> None: + assert cv.temperature("300K") == pytest.approx(300 - 273.15) + + +def test_temperature_fahrenheit() -> None: + assert cv.temperature("32°F") == pytest.approx(0.0) + + +def test_temperature_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature("5x") + + +def test_temperature_delta_celsius() -> None: + assert cv.temperature_delta("5°C") == 5.0 + + +def test_temperature_delta_kelvin() -> None: + assert cv.temperature_delta("5K") == 5.0 + + +def test_temperature_delta_fahrenheit() -> None: + assert cv.temperature_delta("9°F") == pytest.approx(5.0) + + +def test_temperature_delta_invalid() -> None: + with pytest.raises(Invalid, match="Invalid temperature suffix"): + cv.temperature_delta("5x") + + +def test_color_temperature_mireds() -> None: + assert cv.color_temperature("153 mireds") == pytest.approx(153.0) + + +def test_color_temperature_kelvin() -> None: + assert cv.color_temperature("6536 K") == pytest.approx(1000000.0 / 6536) + + +def test_color_temperature_negative() -> None: + with pytest.raises(Invalid, match="cannot be negative"): + cv.color_temperature("-1 mireds") + + +# --------------------------------------------------------------------------- +# validate_bytes +# --------------------------------------------------------------------------- + + +def test_validate_bytes_plain() -> None: + assert cv.validate_bytes("100") == 100 + + +def test_validate_bytes_with_unit() -> None: + assert cv.validate_bytes("2kB") == 2000 + + +def test_validate_bytes_no_match() -> None: + with pytest.raises(Invalid, match="Expected number of bytes"): + cv.validate_bytes("abc") + + +def test_validate_bytes_invalid_suffix() -> None: + with pytest.raises(Invalid, match="Invalid metric suffix"): + cv.validate_bytes("5xx") + + +def test_validate_bytes_negative_exponent() -> None: + with pytest.raises(Invalid, match="positive exponents"): + cv.validate_bytes("5m") + + +# --------------------------------------------------------------------------- +# hostname / domain / domain_name / ssid +# --------------------------------------------------------------------------- + + +def test_hostname_valid() -> None: + assert cv.hostname("my-host01") == "my-host01" + + +def test_hostname_invalid() -> None: + with pytest.raises(Invalid, match="Invalid hostname"): + cv.hostname("invalid_host!") + + +def test_domain_valid_name() -> None: + assert cv.domain("example.com") == "example.com" + + +def test_domain_ip_fallback() -> None: + assert cv.domain("::1") == "::1" + + +def test_domain_invalid() -> None: + with pytest.raises(Invalid, match="Invalid domain"): + cv.domain("::not::valid::") + + +def test_domain_name_empty() -> None: + assert cv.domain_name("") == "" + + +def test_domain_name_valid() -> None: + assert cv.domain_name(".local") == ".local" + + +def test_domain_name_no_leading_dot() -> None: + with pytest.raises(Invalid, match="must start with"): + cv.domain_name("local") + + +def test_domain_name_double_dot() -> None: + with pytest.raises(Invalid, match="single"): + cv.domain_name("..local") + + +def test_domain_name_invalid_char() -> None: + with pytest.raises(Invalid, match="alphanumeric"): + cv.domain_name(".local!") + + +def test_ssid_valid() -> None: + assert cv.ssid("MyNetwork") == "MyNetwork" + + +def test_ssid_empty() -> None: + with pytest.raises(Invalid, match="can't be empty"): + cv.ssid("") + + +def test_ssid_too_long() -> None: + with pytest.raises(Invalid, match="longer than 32"): + cv.ssid("x" * 33) + + +# --------------------------------------------------------------------------- +# IP address / network validators +# --------------------------------------------------------------------------- + + +def test_ipv6address_valid() -> None: + assert str(cv.ipv6address("::1")) == "::1" + + +def test_ipv6address_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 address"): + cv.ipv6address("not-ipv6") + + +def test_ipv4address_multi_broadcast_multicast() -> None: + assert str(cv.ipv4address_multi_broadcast("224.0.0.1")) == "224.0.0.1" + + +def test_ipv4address_multi_broadcast_broadcast() -> None: + assert str(cv.ipv4address_multi_broadcast("255.255.255.255")) == "255.255.255.255" + + +def test_ipv4address_multi_broadcast_invalid() -> None: + with pytest.raises(Invalid, match="not a multicast"): + cv.ipv4address_multi_broadcast("192.168.0.1") + + +def test_ipv4network_valid() -> None: + assert str(cv.ipv4network("192.168.0.0/24")) == "192.168.0.0/24" + + +def test_ipv4network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv4 network"): + cv.ipv4network("notanetwork") + + +def test_ipv6network_valid() -> None: + assert str(cv.ipv6network("2001:db8::/32")) == "2001:db8::/32" + + +def test_ipv6network_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IPv6 network"): + cv.ipv6network("notanetwork") + + +def test_ipnetwork_valid() -> None: + assert str(cv.ipnetwork("10.0.0.0/8")) == "10.0.0.0/8" + + +def test_ipnetwork_invalid() -> None: + with pytest.raises(Invalid, match="not a valid IP network"): + cv.ipnetwork("notanetwork") + + +# --------------------------------------------------------------------------- +# MQTT topic validators +# --------------------------------------------------------------------------- + + +def test_valid_topic_none() -> None: + assert cv._valid_topic(None) == "" + + +def test_valid_topic_dict() -> None: + with pytest.raises(Invalid, match="dictionary with topic"): + cv._valid_topic({"a": 1}) + + +def test_valid_topic_unicode_error() -> None: + with pytest.raises(Invalid, match="valid UTF-8"): + cv._valid_topic("\ud800") + + +def test_valid_topic_empty() -> None: + with pytest.raises(Invalid, match="must not be empty"): + cv._valid_topic("") + + +def test_valid_topic_too_long() -> None: + with pytest.raises(Invalid, match="not be longer than 65535"): + cv._valid_topic("x" * 65536) + + +def test_valid_topic_null_char() -> None: + with pytest.raises(Invalid, match="null character"): + cv._valid_topic("a\0b") + + +def test_subscribe_topic_valid() -> None: + assert cv.subscribe_topic("home/+/temp") == "home/+/temp" + + +def test_subscribe_topic_multilevel() -> None: + assert cv.subscribe_topic("home/#") == "home/#" + + +def test_subscribe_topic_bad_plus() -> None: + with pytest.raises(Invalid, match="Single-level wildcard"): + cv.subscribe_topic("home/a+/temp") + + +def test_subscribe_topic_hash_not_last() -> None: + with pytest.raises(Invalid, match="Multi-level wildcard must be the last"): + cv.subscribe_topic("home/#/temp") + + +def test_subscribe_topic_hash_not_after_separator() -> None: + with pytest.raises(Invalid, match="must be after a topic level separator"): + cv.subscribe_topic("home#") + + +def test_publish_topic_valid() -> None: + assert cv.publish_topic("home/temp") == "home/temp" + + +def test_publish_topic_wildcard() -> None: + with pytest.raises(Invalid, match="Wildcards can not be used"): + cv.publish_topic("home/+") + + +def test_mqtt_payload_none() -> None: + assert cv.mqtt_payload(None) == "" + + +def test_mqtt_payload_value() -> None: + assert cv.mqtt_payload("hello") == "hello" + + +def test_mqtt_qos_valid() -> None: + assert cv.mqtt_qos("1") == 1 + + +def test_mqtt_qos_not_int() -> None: + with pytest.raises(Invalid, match="must be integer"): + cv.mqtt_qos("abc") + + +def test_mqtt_qos_out_of_range() -> None: + with pytest.raises(Invalid): + cv.mqtt_qos(5) + + +# --------------------------------------------------------------------------- +# requires_component / conflicts_with_component +# --------------------------------------------------------------------------- + + +def test_requires_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + assert cv.requires_component("mqtt")("x") == "x" + + +def test_requires_component_not_loaded() -> None: + CORE.loaded_integrations = set() + with pytest.raises(Invalid, match="requires component mqtt"): + cv.requires_component("mqtt")("x") + + +def test_conflicts_with_component_loaded() -> None: + CORE.loaded_integrations = {"mqtt"} + with pytest.raises(Invalid, match="not compatible with component mqtt"): + cv.conflicts_with_component("mqtt")("x") + + +def test_conflicts_with_component_not_loaded() -> None: + CORE.loaded_integrations = set() + assert cv.conflicts_with_component("mqtt")("x") == "x" + + +# --------------------------------------------------------------------------- +# percentage_int / invalid / valid +# --------------------------------------------------------------------------- + + +def test_percentage_int_with_percent() -> None: + assert cv.percentage_int("50%") == 50 + + +def test_percentage_int_plain() -> None: + assert cv.percentage_int(50) == 50 + + +def test_invalid_always_raises() -> None: + with pytest.raises(Invalid, match="my message"): + cv.invalid("my message")("anything") + + +def test_valid_returns_value() -> None: + obj = object() + assert cv.valid(obj) is obj + + +# --------------------------------------------------------------------------- +# prepend_path / remove_prepend_path +# --------------------------------------------------------------------------- + + +def test_prepend_path_single() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path("foo"): + raise Invalid("bad") + assert list(exc_info.value.path) == ["foo"] + + +def test_prepend_path_list() -> None: + with pytest.raises(Invalid) as exc_info, cv.prepend_path(["a", "b"]): + raise Invalid("bad") + assert list(exc_info.value.path) == ["a", "b"] + + +def test_remove_prepend_path_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path(["a"]): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["b"] + + +def test_remove_prepend_path_non_matching() -> None: + with pytest.raises(Invalid) as exc_info, cv.remove_prepend_path("x"): + raise Invalid("bad", path=["a", "b"]) + assert list(exc_info.value.path) == ["a", "b"] + + +# --------------------------------------------------------------------------- +# one_of / enum +# --------------------------------------------------------------------------- + + +def test_one_of_extra_kwargs() -> None: + with pytest.raises(ValueError): + cv.one_of(1, 2, bogus=True) + + +def test_one_of_schema_extract() -> None: + assert cv.one_of("a", "b")(SCHEMA_EXTRACT) == ("a", "b") + + +def test_one_of_string_and_space() -> None: + assert cv.one_of("a_b", string=True, space="_")("a b") == "a_b" + + +def test_one_of_int() -> None: + assert cv.one_of(1, 2, int=True)("2") == 2 + + +def test_one_of_float() -> None: + assert cv.one_of(1.0, 2.0, float=True)("2.0") == 2.0 + + +def test_one_of_lower() -> None: + assert cv.one_of("abc", lower=True)("ABC") == "abc" + + +def test_one_of_upper() -> None: + assert cv.one_of("ABC", upper=True)("abc") == "ABC" + + +def test_one_of_unknown_with_suggestion() -> None: + with pytest.raises(Invalid, match="did you mean"): + cv.one_of("apple", "banana")("aple") + + +def test_one_of_unknown_no_suggestion() -> None: + with pytest.raises(Invalid, match="valid options are"): + cv.one_of("apple", "banana")("zzzzzz") + + +def test_enum_schema_extract() -> None: + mapping = {"a": 1, "b": 2} + assert cv.enum(mapping)(SCHEMA_EXTRACT) == mapping + + +def test_enum_valid() -> None: + mapping = {"a": 10, "b": 20} + result = cv.enum(mapping)("a") + assert result == "a" + assert result.enum_value == 10 + + +# --------------------------------------------------------------------------- +# lambda_ / returning_lambda +# --------------------------------------------------------------------------- + + +def test_lambda_from_string() -> None: + result = cv.lambda_(_wrap_str("return 5;")) + assert isinstance(result, Lambda) + assert result.value == "return 5;" + + +def test_lambda_existing_lambda() -> None: + lam = Lambda("x") + assert cv.lambda_(lam) is lam + + +def test_lambda_entity_id_reference() -> None: + with pytest.raises(Invalid, match="entity-id-style ID"): + cv.lambda_(Lambda("return id(light.living_room);")) + + +def test_returning_lambda_valid() -> None: + assert isinstance(cv.returning_lambda(_wrap_str("return 5;")), Lambda) + + +def test_returning_lambda_no_return() -> None: + with pytest.raises(Invalid, match="return statement"): + cv.returning_lambda(Lambda("int x = 5;")) + + +# --------------------------------------------------------------------------- +# dimensions +# --------------------------------------------------------------------------- + + +def test_dimensions_list_valid() -> None: + assert cv.dimensions([320, 240]) == [320, 240] + + +def test_dimensions_list_wrong_length() -> None: + with pytest.raises(Invalid, match="length of two"): + cv.dimensions([1, 2, 3]) + + +def test_dimensions_list_non_int() -> None: + with pytest.raises(Invalid, match="must be integers"): + cv.dimensions(["a", "b"]) + + +def test_dimensions_list_non_positive() -> None: + with pytest.raises(Invalid, match="at least be 1"): + cv.dimensions([0, 240]) + + +def test_dimensions_string_valid() -> None: + assert cv.dimensions("320x240") == [320, 240] + + +def test_dimensions_number_invalid() -> None: + with pytest.raises(Invalid, match="must be a string"): + cv.dimensions(320) + + +def test_dimensions_string_invalid() -> None: + with pytest.raises(Invalid, match="Only WIDTHxHEIGHT"): + cv.dimensions("notdimensions") + + +# --------------------------------------------------------------------------- +# entity_id +# --------------------------------------------------------------------------- + + +def test_entity_id_valid() -> None: + assert cv.entity_id("Light.Living_Room") == "light.living_room" + + +def test_entity_id_no_dot() -> None: + with pytest.raises(Invalid, match="exactly one dot"): + cv.entity_id("nodot") + + +def test_entity_id_invalid_char() -> None: + with pytest.raises(Invalid, match="Invalid character"): + cv.entity_id("light.living!room") + + +# --------------------------------------------------------------------------- +# extract_keys / typed_schema +# --------------------------------------------------------------------------- + + +def test_extract_keys_from_schema() -> None: + schema = cv.Schema({cv.Optional("b"): cv.int_, cv.Required("a"): cv.int_}) + assert cv.extract_keys(schema) == ["a", "b"] + + +def test_extract_keys_from_dict() -> None: + assert cv.extract_keys({"x": cv.int_, cv.Optional("y"): cv.int_}) == ["x", "y"] + + +def test_extract_keys_invalid_key() -> None: + with pytest.raises(ValueError): + cv.extract_keys({1: cv.int_}) + + +def test_typed_schema_basic() -> None: + schema = cv.typed_schema({"foo": cv.Schema({cv.Optional("x"): cv.int_})}) + assert schema({"type": "foo", "x": 5}) == {"type": "foo", "x": 5} + + +def test_typed_schema_not_dict() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="must be dict"): + schema("notdict") + + +def test_typed_schema_missing_key() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}) + with pytest.raises(Invalid, match="type not specified"): + schema({"x": 5}) + + +def test_typed_schema_default_type() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, default_type="foo") + assert schema({}) == {"type": "foo"} + + +def test_typed_schema_with_enum() -> None: + schema = cv.typed_schema({"foo": cv.Schema({})}, enum={"foo": 42}) + result = schema({"type": "foo"}) + assert result["type"] == "foo" + assert result["type"].enum_value == 42 + + +# --------------------------------------------------------------------------- +# SplitDefault / OnlyWithout +# --------------------------------------------------------------------------- + + +def test_split_default_no_match() -> None: + _set_core_target(PLATFORM_ESP8266, "arduino") + schema = cv.Schema({cv.SplitDefault("key", esp32="value"): cv.string}) + assert "key" not in schema({}) + + +def test_only_without_component_absent() -> None: + CORE.loaded_integrations = set() + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert schema({})["key"] == "dval" + + +def test_only_without_component_present() -> None: + CORE.loaded_integrations = {"mqtt"} + schema = cv.Schema({cv.OnlyWithout("key", "mqtt", default="dval"): cv.string}) + assert "key" not in schema({}) + + +# --------------------------------------------------------------------------- +# _entity_base_validator / ensure_schema +# --------------------------------------------------------------------------- + + +def test_entity_base_validator_name_present() -> None: + result = cv._entity_base_validator({CONF_NAME: "My Name"}) + assert result[CONF_NAME] == "My Name" + + +def test_entity_base_validator_neither() -> None: + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator({}) + + +def test_entity_base_validator_id_not_manual() -> None: + config = {CONF_ID: ID("auto", is_declaration=True, type=int, is_manual=False)} + with pytest.raises(Invalid, match="'id:' or 'name:' is required"): + cv._entity_base_validator(config) + + +def test_entity_base_validator_id_manual() -> None: + config = {CONF_ID: ID("myid", is_declaration=True, type=int, is_manual=True)} + result = cv._entity_base_validator(config) + assert result[CONF_NAME] == "myid" + assert result[CONF_INTERNAL] is True + + +def test_entity_base_validator_name_none() -> None: + result = cv._entity_base_validator({CONF_NAME: None}) + assert result[CONF_NAME] == "" + + +def test_ensure_schema_passthrough() -> None: + schema = cv.Schema({}) + assert cv.ensure_schema(schema) is schema + + +def test_ensure_schema_wraps() -> None: + result = cv.ensure_schema({cv.Optional("x"): cv.int_}) + assert isinstance(result, cv.Schema) + + +# --------------------------------------------------------------------------- +# validate_registry_entry +# --------------------------------------------------------------------------- + + +def _make_registry(*names: str, type_id: object = int) -> Registry: + registry = Registry() + for name in names: + registry.register(name, type_id, cv.Schema({cv.Optional("param"): cv.int_}))( + lambda: None + ) + return registry + + +def test_validate_registry_entry_string_shorthand() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)("foo") + assert "foo" in result + + +def test_validate_registry_entry_not_mapping() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="must consist of key-value mapping"): + cv.validate_registry_entry("action", registry)(5) + + +def test_validate_registry_entry_missing_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Key missing"): + cv.validate_registry_entry("action", registry)({}) + + +def test_validate_registry_entry_unknown_key() -> None: + registry = _make_registry() + with pytest.raises(Invalid, match="Unable to find action"): + cv.validate_registry_entry("action", registry)({"unknown": {}}) + + +def test_validate_registry_entry_two_keys() -> None: + registry = _make_registry("foo", "bar") + with pytest.raises(Invalid, match="Cannot have two action"): + cv.validate_registry_entry("action", registry)({"foo": {}, "bar": {}}) + + +def test_validate_registry_entry_none_value() -> None: + registry = _make_registry("foo") + result = cv.validate_registry_entry("action", registry)({"foo": None}) + assert "foo" in result + + +def test_validate_registry_entry_no_type_id() -> None: + registry = _make_registry("foo", type_id=None) + result = cv.validate_registry_entry("action", registry)({"foo": {}}) + assert "foo" in result + + +# --------------------------------------------------------------------------- +# maybe_simple_value / entity_category +# --------------------------------------------------------------------------- + + +def test_maybe_simple_value_schema_extract() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + validator, key = cv.maybe_simple_value(schema)(SCHEMA_EXTRACT) + assert key == CONF_VALUE + + +def test_maybe_simple_value_dict_with_key() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)({"value": "x"}) == {"value": "x"} + + +def test_maybe_simple_value_plain() -> None: + schema = cv.Schema({cv.Required(CONF_VALUE): cv.string}) + assert cv.maybe_simple_value(schema)("x") == {"value": "x"} + + +def test_maybe_simple_value_custom_key() -> None: + schema = cv.Schema({cv.Required("name"): cv.string}) + assert cv.maybe_simple_value(schema, key="name")({"name": "x"}) == {"name": "x"} + + +def test_entity_category_valid() -> None: + assert cv.entity_category("config") == "config" + + +def test_entity_category_invalid() -> None: + with pytest.raises(Invalid): + cv.entity_category("bogus") + + +# --------------------------------------------------------------------------- +# url / git_ref / source_refresh / version helpers +# --------------------------------------------------------------------------- + + +def test_url_valid() -> None: + assert cv.url("https://example.com/path") == "https://example.com/path" + + +def test_url_file_scheme() -> None: + assert cv.url("file:///tmp/x") == "file:///tmp/x" + + +def test_url_invalid_value_error() -> None: + with pytest.raises(Invalid, match="Not a valid URL"): + cv.url("http://[::1") + + +def test_url_no_host() -> None: + with pytest.raises(Invalid, match="Expected a file scheme"): + cv.url("notaurl") + + +def test_git_ref_valid() -> None: + assert cv.git_ref("v1.2.3") == "v1.2.3" + + +def test_git_ref_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid git ref"): + cv.git_ref("!!!") + + +def test_source_refresh_always() -> None: + assert cv.source_refresh("always").total_seconds == 0 + + +def test_source_refresh_never() -> None: + assert cv.source_refresh("never").total_seconds == 365250 * 24 * 3600 + + +def test_source_refresh_value() -> None: + assert cv.source_refresh("60s").total_seconds == 60 + + +def test_version_number_valid() -> None: + assert cv.version_number("2024.5.1") == "2024.5.1" + + +def test_version_number_invalid() -> None: + with pytest.raises(Invalid, match="Not a valid version number"): + cv.version_number("notaversion") + + +def test_validate_esphome_version_ok() -> None: + assert cv.validate_esphome_version("1.0.0") == "1.0.0" + + +def test_validate_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="ESPHome version is too old"): + cv.validate_esphome_version("9999.0.0") + + +def test_platformio_version_constraint_no_op() -> None: + assert cv.platformio_version_constraint("1.2.3") == [(None, "1.2.3")] + + +def test_platformio_version_constraint_with_ops() -> None: + assert cv.platformio_version_constraint(">=1.2.3,<2.0.0") == [ + (">=", "1.2.3"), + ("<", "2.0.0"), + ] + + +# --------------------------------------------------------------------------- +# require_framework_version (no extra_message) / require_esphome_version +# --------------------------------------------------------------------------- + + +def test_require_framework_version_incompatible_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="incompatible with ESP32"): + cv.require_framework_version()("test") + + +def test_require_framework_version_too_low_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(1, 0, 0)) + with pytest.raises(Invalid, match="at least framework version 2.0.0"): + cv.require_framework_version(esp32_arduino=cv.Version(2, 0, 0))("test") + + +def test_require_framework_version_too_high_no_extra() -> None: + _set_framework_version(PLATFORM_ESP32, "arduino", cv.Version(2, 0, 0)) + with pytest.raises(Invalid, match="version 1.0.0 or lower"): + cv.require_framework_version( + esp32_arduino=cv.Version(1, 0, 0), max_version=True + )("test") + + +def test_require_esphome_version_ok() -> None: + assert cv.require_esphome_version(1, 0, 0)("test") == "test" + + +def test_require_esphome_version_too_old() -> None: + with pytest.raises(Invalid, match="at least ESPHome version 9999.0.0"): + cv.require_esphome_version(9999, 0, 0)("test") + + +# --------------------------------------------------------------------------- +# suppress_invalid / validate_source_shorthand / rename_key +# --------------------------------------------------------------------------- + + +def test_suppress_invalid() -> None: + with cv.suppress_invalid(): + raise Invalid("suppressed") + + +def test_validate_source_shorthand_not_string() -> None: + with pytest.raises(Invalid, match="Shorthand only for strings"): + cv.validate_source_shorthand(123) + + +def test_validate_source_shorthand_local_path(setup_core: Path) -> None: + (setup_core / "mydir").mkdir() + result = cv.validate_source_shorthand("mydir") + assert result[CONF_TYPE] == TYPE_LOCAL + + +def test_validate_source_shorthand_github(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo@main") + assert result[CONF_TYPE] == TYPE_GIT + assert result[CONF_REF] == "main" + + +def test_validate_source_shorthand_github_no_ref(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://user/repo") + assert result[CONF_TYPE] == TYPE_GIT + assert CONF_REF not in result + + +def test_validate_source_shorthand_github_pr(setup_core: Path) -> None: + result = cv.validate_source_shorthand("github://pr#1234") + assert result[CONF_REF] == "pull/1234/head" + + +def test_validate_source_shorthand_invalid(setup_core: Path) -> None: + with pytest.raises(Invalid, match="not a file system path"): + cv.validate_source_shorthand("notvalid") + + +def test_rename_key_present() -> None: + assert cv.rename_key("old", "new")({"old": 5}) == {"new": 5} + + +def test_rename_key_absent() -> None: + assert cv.rename_key("old", "new")({"other": 5}) == {"other": 5} diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index a61b6ae7ae..0cb0c1f62d 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -591,6 +591,36 @@ class TestEsphomeCore: assert target.is_esp32 is False assert target.is_esp8266 is True + def test_is_rp2(self, target): + """The canonical RP2 family gate flips on for the rp2 platform.""" + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + + assert target.is_rp2 is True + assert target.is_esp32 is False + assert target.is_esp8266 is False + + def test_is_rp2040_deprecated_alias_matches_is_rp2(self, target, caplog): + """``is_rp2040`` is kept as a deprecation shim that returns whatever + ``is_rp2`` returns; both must agree across platform values. A + one-shot deprecation warning is emitted on first access and + deduped via ``CORE.data`` for the rest of the run.""" + import logging + + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "rp2"} + with caplog.at_level(logging.WARNING, logger="esphome.core"): + assert target.is_rp2040 is True + assert target.is_rp2040 == target.is_rp2 + + warnings = [r for r in caplog.records if "is_rp2040" in r.message] + assert len(warnings) == 1 + assert "2027.7.0" in warnings[0].message + + # Reset the dedupe so the False-platform branch also runs the shim. + target.data.pop("_core_is_rp2040_deprecated_warned", None) + target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} + assert target.is_rp2040 is False + assert target.is_rp2040 == target.is_rp2 + def test_firmware_bin__default(self, target): """Default platforms produce //firmware.bin.""" target.name = "test-device" diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index 87e168dc94..a50024b8e9 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -14,23 +14,23 @@ from esphome.const import ( Platform, ) from esphome.core import CORE, Library -import esphome.espidf.component from esphome.espidf.component import ( + generate_cmakelists_txt, + generate_idf_component_yml, + generate_idf_components, +) +import esphome.platformio.library +from esphome.platformio.library import ( + ConvertedLibrary as IDFComponent, GitSource, - IDFComponent, - InvalidIDFComponent, URLSource, - _check_library_data, - _collect_filtered_files, _node_key, _normalize_dependencies, _parse_library_json, _parse_library_properties, _resolve_registry_version, - _split_list_by_condition, - generate_cmakelists_txt, - generate_idf_component_yml, - generate_idf_components, + collect_filtered_files, + split_list_by_condition, ) @@ -70,7 +70,7 @@ def test_collect_filtered_files_basic(tmp_path): f2.parent.mkdir(parents=True) f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*>"]) + result = collect_filtered_files(tmp_path, ["+<*>"]) assert str(f1) in result assert str(f2) in result @@ -81,7 +81,7 @@ def test_collect_filtered_files_exclude(tmp_path): f1.write_text("int a;") f2.write_text("int b;") - result = _collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) + result = collect_filtered_files(tmp_path, ["+<*> -<*.cpp>"]) assert str(f1) in result assert str(f2) not in result @@ -89,7 +89,7 @@ def test_collect_filtered_files_exclude(tmp_path): def test_split_list_by_condition(): items = ["-Iinclude", "-Llib", "-Wall"] - matched, rest = _split_list_by_condition( + matched, rest = split_list_by_condition( items, lambda x: x[2:] if x.startswith("-I") else None ) @@ -202,41 +202,6 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component): generate_idf_component_yml(tmp_component) -def test_check_library_data_valid(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "*"}) - - -def test_check_library_data_valid2(esp32_idf_core): - _check_library_data({"platforms": "*"}) - - -def test_check_library_data_valid3(esp32_idf_core): - _check_library_data({}) - - -def test_check_library_data_valid4(esp32_idf_core): - _check_library_data({"platforms": "espressif32", "frameworks": "*"}) - - -def test_check_library_data_valid5(esp32_idf_core): - _check_library_data({"platforms": "*", "frameworks": "espidf"}) - - -def test_check_library_data_invalid_platform(esp32_idf_core): - with pytest.raises(InvalidIDFComponent): - _check_library_data({"platforms": ["other"], "frameworks": "*"}) - - -def test_check_library_data_invalid_framework( - esp32_idf_core: None, caplog: pytest.LogCaptureFixture -) -> None: - # Framework mismatch is a warning, not a hard skip: the library is still - # included so that PIO manifests that only list "arduino" (but actually - # compile under IDF) can be used without forking them. - _check_library_data({"name": "lib", "platforms": "*", "frameworks": ["other"]}) - assert "do not include 'espidf'" in caplog.text - - def test_extra_script_captures_libpath_libs_and_defines(tmp_path): from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script @@ -453,7 +418,7 @@ def _patch_registry(monkeypatch, versions): ``get_compatible_registry_versions`` / ``pick_best_registry_version`` run on the canned data so the intersection logic is exercised for real. """ - registry = esphome.espidf.component._make_registry_client() + registry = esphome.platformio.library._make_registry_client() monkeypatch.setattr( registry, "fetch_registry_package", @@ -467,7 +432,7 @@ def _patch_registry(monkeypatch, versions): }, ) monkeypatch.setattr( - esphome.espidf.component, "_make_registry_client", lambda: registry + esphome.platformio.library, "_make_registry_client", lambda: registry ) @@ -516,7 +481,7 @@ def test_generate_idf_components_dedupes_shared_dependency( "esphome/C": {"name": "C"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): 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;") @@ -535,7 +500,7 @@ def test_generate_idf_components_dedupes_shared_dependency( return owner, pkgname, version, f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) top = generate_idf_components( @@ -578,7 +543,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( download_salts: list[str] = [] - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): download_salts.append(salt) self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) @@ -594,7 +559,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies( return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz" monkeypatch.setattr( - esphome.espidf.component, "_resolve_registry_version", fake_resolve + esphome.platformio.library, "_resolve_registry_version", fake_resolve ) # lib_ignore is read from CORE.platformio_options (stored there by # _add_platformio_options); matched by lowercase short name. @@ -632,7 +597,7 @@ def test_generate_idf_components_handles_dependency_cycle( }, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): 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;") @@ -640,7 +605,7 @@ def test_generate_idf_components_handles_dependency_cycle( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -689,7 +654,7 @@ def test_generate_idf_components_git_overrides_registry_warns( "esphome/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): 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;") @@ -697,7 +662,7 @@ def test_generate_idf_components_git_overrides_registry_warns( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -726,14 +691,14 @@ 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, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) # no library.json / library.properties written monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -768,7 +733,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( "owner/shared": {"name": "shared"}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): 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;") @@ -777,7 +742,7 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate( monkeypatch.setattr(IDFComponent, "download", fake_download) # Bare "shared" and "owner/shared" both resolve to canonical owner/shared. monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner or "owner", @@ -801,7 +766,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, salt=""): + def fake_download(self, force=False, salt="", namespace=""): self.path = tmp_path / self.get_sanitized_name().replace("/", "__") (self.path / "src").mkdir(parents=True, exist_ok=True) (self.path / "library.json").write_text( @@ -810,7 +775,7 @@ def test_generate_idf_components_incompatible_top_level_raises( monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -820,7 +785,7 @@ def test_generate_idf_components_incompatible_top_level_raises( ), ) - with pytest.raises(RuntimeError, match="not compatible with ESP-IDF"): + with pytest.raises(RuntimeError, match="not compatible with espidf"): generate_idf_components([Library("esphome/A", "1.0.0", None)]) @@ -839,14 +804,14 @@ def test_generate_idf_components_incompatible_dependency_skipped( "esphome/B": {"name": "B", "platforms": ["espressif8266"]}, } - def fake_download(self, force=False, salt=""): + def fake_download(self, force=False, salt="", namespace=""): 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])) monkeypatch.setattr(IDFComponent, "download", fake_download) monkeypatch.setattr( - esphome.espidf.component, + esphome.platformio.library, "_resolve_registry_version", lambda owner, pkgname, requirements: ( owner, @@ -882,6 +847,13 @@ def test_url_source_salt_changes_cache_path( assert source.download("lib") == expected[""] assert source.download("lib", salt="abcd1234") == expected["abcd1234"] + # A backend namespace adds a pio_components// subdir. + digest = hashlib.sha256(url.encode()).hexdigest()[:8] + ns_expected = base / "idf" / digest / "lib" + ns_expected.mkdir(parents=True) + (ns_expected / ".esphome_extracted").touch() + assert source.download("lib", namespace="idf") == ns_expected + def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: """The salt becomes a subdirectory of the git clone domain.""" @@ -892,13 +864,20 @@ def test_git_source_salt_scopes_domain(monkeypatch: pytest.MonkeyPatch) -> None: return Path("/cloned"), None monkeypatch.setattr( - esphome.espidf.component.git, "clone_or_update", fake_clone_or_update + esphome.platformio.library.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"] + source.download("noise-c", namespace="idf") + source.download("noise-c", namespace="zephyr", salt="abcd1234") + assert domains == [ + "pio_components", + "pio_components/abcd1234", + "pio_components/idf", + "pio_components/zephyr/abcd1234", + ] def test_idf_component_download_passes_salt() -> None: @@ -908,7 +887,9 @@ def test_idf_component_download_passes_salt() -> None: source.download.return_value = Path("/converted/owner/name") c = IDFComponent("owner/name", "1.0", source=source) - c.download(force=True, salt="abcd1234") + c.download(force=True, salt="abcd1234", namespace="idf") - source.download.assert_called_once_with("owner/name", force=True, salt="abcd1234") + source.download.assert_called_once_with( + "owner/name", force=True, salt="abcd1234", namespace="idf" + ) assert c.path == Path("/converted/owner/name") diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index e0f63e89b4..c5d9ddbaf1 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -15,12 +15,12 @@ from unittest.mock import patch import pytest from esphome.espidf.framework import ( + _ccache_env, _check_stamp, _check_windows_path_length, _clone_idf_with_submodules, _get_framework_path, _get_idf_tool_paths, - _get_idf_tools_path, _get_idf_version, _get_python_env_path, _get_python_version, @@ -31,10 +31,24 @@ from esphome.espidf.framework import ( _write_stamp, check_esp_idf_install, get_framework_env, + get_idf_tools_path, ) from esphome.framework_helpers import _tar_extract_all, get_python_env_executable_path +@pytest.fixture(autouse=True) +def _isolate_idf_install_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the ESP-IDF install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the framework dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the env + themselves. + """ + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(tmp_path / "idf_install")) + + @pytest.mark.parametrize( ("source", "expected"), [ @@ -625,7 +639,7 @@ def test_write_stamp_writes_json(tmp_path: Path) -> None: def test_get_framework_env_with_python_env(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), @@ -633,6 +647,8 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: "esphome.espidf.framework._get_idf_tool_paths", return_value=(["/tool/bin"], {"IDF_X": "1"}), ), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env( tmp_path / "fw", tmp_path / "penv", {"PATH": "/usr/bin"} @@ -648,11 +664,13 @@ def test_get_framework_env_with_python_env(tmp_path: Path) -> None: def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> None: with ( patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), patch("esphome.espidf.framework._get_idf_version", return_value="5.1.2"), patch("esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})), + # ccache env is covered separately; keep this test host-independent. + patch("esphome.espidf.framework._ccache_env", return_value={}), ): env = get_framework_env(tmp_path / "fw") @@ -661,7 +679,89 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No # --------------------------------------------------------------------------- -# _check_stamp / _write_idf_version_txt / _get_idf_tools_path +# _ccache_env +# --------------------------------------------------------------------------- + + +def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): + return ( + patch("esphome.espidf.framework.shutil.which", return_value=which), + patch( + "esphome.espidf.framework.get_idf_tools_path", + return_value=tmp_path / "tools", + ), + patch( + "esphome.espidf.framework.CORE", + SimpleNamespace(build_path=build_path), + ), + ) + + +def test_ccache_env_default_enabled_when_available(tmp_path: Path) -> None: + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + env = _ccache_env() + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_NOHASHDIR"] == "true" + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve()) + + +def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: + # build_path is None here too: a disabled cache must not require it. + p1, p2, p3 = _ccache_patches(tmp_path, None, None) + with patch.dict("os.environ", {}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=0 wins even when the binary is present, and + # short-circuits before build_path is needed. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: + assert _ccache_env() == {} + + +def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: + # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's + # already in the environment, so it isn't re-emitted, but the rest is. + p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: + env = _ccache_env() + assert "IDF_CCACHE_ENABLE" not in env + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: + # User-set CCACHE_* values must not be clobbered; unset ones still default. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + user_env = {"CCACHE_DIR": "/my/cache", "CCACHE_MAXSIZE": "9G"} + with patch.dict("os.environ", user_env, clear=True), p1, p2, p3: + env = _ccache_env() + assert "CCACHE_DIR" not in env + assert "CCACHE_MAXSIZE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" + assert env["CCACHE_DEPEND"] == "1" + + +def test_ccache_env_raises_without_build_path(tmp_path: Path) -> None: + # Enabled but no build_path means the IDF env was built too early -- fail + # loudly instead of silently dropping CCACHE_BASEDIR. + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) + with ( + patch.dict("os.environ", {}, clear=True), + p1, + p2, + p3, + pytest.raises(ValueError, match="build_path"), + ): + _ccache_env() + + +# --------------------------------------------------------------------------- +# _check_stamp / _write_idf_version_txt / get_idf_tools_path # --------------------------------------------------------------------------- @@ -698,10 +798,42 @@ def test_write_idf_version_txt_skips_when_present(tmp_path: Path) -> None: assert (tmp_path / "version.txt").read_text(encoding="utf-8") == "existing\n" -def test_get_idf_tools_path_env_override(tmp_path: Path) -> None: +def testget_idf_tools_path_env_override(tmp_path: Path) -> None: override = str(tmp_path / "custom-idf") with patch.dict("os.environ", {"ESPHOME_ESP_IDF_PREFIX": override}): - assert _get_idf_tools_path() == Path(override) + assert get_idf_tools_path() == Path(override) + + +@pytest.mark.parametrize("value", ["", " "]) +def testget_idf_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_ESP_IDF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could then + delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert get_idf_tools_path() == expected + + +def testget_idf_tools_path_default_uses_user_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without the env override the install root is the machine-global OS user + cache dir, not the per-config ``/idf``.""" + import platformdirs + + monkeypatch.delenv("ESPHOME_ESP_IDF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" + ).resolve() + assert get_idf_tools_path() == expected def test_write_idf_version_txt_warns_on_write_error(tmp_path: Path) -> None: @@ -776,7 +908,7 @@ def test_check_windows_path_length_noop_when_long_paths_enabled( patch( "esphome.espidf.framework._windows_long_paths_enabled", return_value=True ), - patch("esphome.espidf.framework._get_idf_tools_path") as get_path_mock, + patch("esphome.espidf.framework.get_idf_tools_path") as get_path_mock, caplog.at_level(logging.WARNING), ): _check_windows_path_length() @@ -793,7 +925,7 @@ def test_check_windows_path_length_short_path_silent( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_SHORT_IDF_PATH, ), caplog.at_level(logging.WARNING), @@ -811,7 +943,7 @@ def test_check_windows_path_length_long_path_warns( "esphome.espidf.framework._windows_long_paths_enabled", return_value=False ), patch( - "esphome.espidf.framework._get_idf_tools_path", + "esphome.espidf.framework.get_idf_tools_path", return_value=_LONG_IDF_PATH, ), caplog.at_level(logging.WARNING), @@ -821,3 +953,5 @@ def test_check_windows_path_length_long_path_warns( message = caplog.records[0].getMessage() assert _LONG_IDF_PATH in message assert "long path support" in message + # The install is global now; the remedy is the prefix env, not moving the project. + assert "ESPHOME_ESP_IDF_PREFIX" in message diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index a8533608c0..69b9f20eaa 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -25,6 +25,9 @@ from esphome.framework_helpers import ( archive_extract_all, create_venv, download_from_mirrors, + get_project_compile_flags, + get_project_cxx_compile_flags, + get_project_link_flags, get_python_env_executable_path, get_system_python_path, rmdir, @@ -524,7 +527,7 @@ class TestDownloadFromMirrors: def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None: target = tmp_path / "out.bin" with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"filedata"), ): url = download_from_mirrors(["https://example.com/f"], {}, target) @@ -533,7 +536,7 @@ class TestDownloadFromMirrors: def test_substitutions_applied_to_url(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"x"), ) as mock_get: download_from_mirrors( @@ -545,7 +548,7 @@ class TestDownloadFromMirrors: def test_falls_back_to_second_mirror(self, tmp_path: Path) -> None: with patch( - "esphome.framework_helpers.requests.get", + "requests.get", side_effect=[_mock_response(b"", ok=False), _mock_response(b"second")], ): url = download_from_mirrors( @@ -559,7 +562,7 @@ class TestDownloadFromMirrors: def test_all_mirrors_fail_reraises_last_exception(self, tmp_path: Path) -> None: with ( patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"", ok=False), ), pytest.raises(req.HTTPError), @@ -577,7 +580,7 @@ class TestDownloadFromMirrors: def test_file_like_target_written(self) -> None: buf = io.BytesIO() with patch( - "esphome.framework_helpers.requests.get", + "requests.get", return_value=_mock_response(b"bytes"), ): download_from_mirrors(["https://example.com/f"], {}, buf) @@ -588,7 +591,7 @@ class TestDownloadFromMirrors: r = _mock_response(b"1234567890") r.headers = {"content-length": "10"} with ( - patch("esphome.framework_helpers.requests.get", return_value=r), + patch("requests.get", return_value=r), patch("esphome.framework_helpers.ProgressBar") as mock_pb, ): download_from_mirrors(["https://example.com/f"], {}, tmp_path / "out.bin") @@ -604,12 +607,35 @@ class TestDownloadFromMirrors: r.headers = {"content-length": "0"} r.iter_content.return_value = [b""] # one empty chunk target = tmp_path / "out.bin" - with patch("esphome.framework_helpers.requests.get", return_value=r): + with patch("requests.get", return_value=r): download_from_mirrors(["https://example.com/f"], {}, target) assert target.exists() assert target.read_bytes() == b"" +def test_importing_framework_helpers_does_not_import_requests() -> None: + """Importing framework_helpers must not drag in requests. + + requests is a heavy import (~85ms) only needed by download_from_mirrors to + fetch toolchains during a build. framework_helpers is loaded during config + validation (esp-idf framework, host platform), so the import is deferred to + the function that uses it. A fresh interpreter is required because the test + process has already imported requests. + """ + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys\nimport esphome.framework_helpers\n" + "print('\\n'.join(sys.modules))", + ], + capture_output=True, + text=True, + check=True, + ) + assert "requests" not in result.stdout.split() + + # --------------------------------------------------------------------------- # get_python_env_executable_path — Windows branch # --------------------------------------------------------------------------- @@ -633,11 +659,6 @@ def test_get_python_env_executable_path_nt() -> None: class TestTarExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" info = tarfile.TarInfo(name="C:/secret.txt") @@ -730,11 +751,6 @@ class TestTarExtractAllBranches: class TestZipExtractAllBranches: - @pytest.mark.skipif( - sys.version_info < (3, 12), - reason="patching os.name makes pathlib build a WindowsPath, which only " - "instantiates on POSIX in 3.12+", - ) def test_windows_drive_path_skipped(self, tmp_path: Path) -> None: """Windows-style drive path (C:/...) is skipped when os.name == 'nt'.""" buf = _make_zip([("C:/secret.txt", "bad")]) @@ -952,3 +968,106 @@ 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) + + +def _make_core_cxx(flags: set[str]) -> MagicMock: + core = MagicMock() + core.cxx_build_flags = flags + return core + + +class TestGetProjectCxxCompileFlags: + def test_returns_sorted_flags(self) -> None: + with patch( + "esphome.core.CORE", + _make_core_cxx({"-Wno-volatile", "-Wno-deprecated"}), + ): + assert get_project_cxx_compile_flags() == [ + "-Wno-deprecated", + "-Wno-volatile", + ] + + def test_empty_flags(self) -> None: + with patch("esphome.core.CORE", _make_core_cxx(set())): + assert get_project_cxx_compile_flags() == [] diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 70c4b90082..fad249b0bb 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -121,22 +121,6 @@ def test_friendly_name_slugify(value, expected): assert helpers.friendly_name_slugify(value) == expected -def test_friendly_name_slugify_back_compat_shim(): - """``esphome.dashboard.util.text`` keeps re-exporting for back-compat. - - The function moved to ``esphome.helpers`` so the new - device-builder dashboard backend can import it without depending - on the legacy dashboard package, but downstream code that still - imports from the old path keeps working until the dashboard - module is removed. - """ - from esphome.dashboard.util.text import ( - friendly_name_slugify as legacy_friendly_name_slugify, - ) - - assert legacy_friendly_name_slugify is helpers.friendly_name_slugify - - @pytest.mark.parametrize( "host", ( diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 3fb0eca4a0..41dd462678 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -1,8 +1,23 @@ """Unit tests for esphome.loader module.""" +import ast +from pathlib import Path +import sys +import textwrap from unittest.mock import MagicMock, patch -from esphome.loader import ComponentManifest, _replace_component_manifest, get_component +import pytest + +from esphome.loader import ( + AliasMeta, + ComponentManifest, + _AliasFinder, + _build_alias_map, + _read_aliases, + _replace_component_manifest, + get_alias_metadata, + get_component, +) from tests.testing_helpers import ComponentManifestOverride # --------------------------------------------------------------------------- @@ -322,3 +337,323 @@ 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) +# --------------------------------------------------------------------------- +# +# The framework here is the substrate behind `ALIASES = [...]` on component +# `__init__.py` files. These tests pin down the AST scanner, the resulting +# global alias map, the Python-import `sys.meta_path` finder, and the +# integration with `get_component`. The rp2 → rp2040 actual mapping in this +# repo is used as a real-world fixture; other cases use temp dirs / mocks so +# the framework's behavior is testable in isolation. + + +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.7.0" + """) + ) + aliases, removal = _read_aliases(init, ast) + assert aliases == ["old"] + assert removal == "2027.7.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) -> None: + """A broken __init__.py shouldn't crash the alias scanner — it'll + surface as an ImportError elsewhere, but the scanner just yields + nothing so other components keep working. + + The source must contain the substring ``ALIASES`` so the scanner + actually attempts to parse the file; otherwise the early-return + optimization would short-circuit before reaching the parser and + this test would not exercise the syntax-error branch. + """ + init = tmp_path / "__init__.py" + init.write_text("ALIASES = ['oops'\ndef broken( :\n") + assert _read_aliases(init, ast) == ([], None) + + +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" + assert not fake.exists() + with patch("esphome.loader.CORE_COMPONENTS_PATH", fake): + alias_map, meta_map = _build_alias_map() + assert alias_map == {} + assert meta_map == {} + + +# ---- Live integration against the real rp2/rp2040 mapping in this repo ---- + + +def test_real_alias_map_includes_rp2040() -> None: + """The rp2 component declares ``ALIASES = ['rp2040']`` in this repo; + the live alias map should surface it. This guards against future + refactors silently dropping the declaration.""" + meta = get_alias_metadata() + assert "rp2040" in meta + assert meta["rp2040"].canonical == "rp2" + assert meta["rp2040"].removal_version == "2027.7.0" + + +def test_get_component_resolves_alias() -> None: + """``get_component('rp2040')`` should return the rp2 manifest — every + caller of the loader (dep checker, schema validator, codegen) hits + the canonical component without knowing about the alias.""" + rp2 = get_component("rp2") + rp2040 = get_component("rp2040") + assert rp2 is not None + assert rp2040 is rp2 + + +def test_alias_finder_resolves_top_level_import() -> None: + """``import esphome.components.rp2040`` resolves to the canonical + module via the meta-path finder.""" + # Remove any cached entry so we exercise the finder, not sys.modules cache. + sys.modules.pop("esphome.components.rp2040", None) + finder = _AliasFinder() + spec = finder.find_spec("esphome.components.rp2040", None) + assert spec is not None + + import esphome.components.rp2 + import esphome.components.rp2040 + + assert esphome.components.rp2040 is esphome.components.rp2 + + +def test_alias_finder_resolves_submodule_import() -> None: + """``from esphome.components.rp2040 import boards`` routes through to + ``esphome.components.rp2.boards`` — same submodule object on both + paths.""" + sys.modules.pop("esphome.components.rp2040.boards", None) + finder = _AliasFinder() + spec = finder.find_spec("esphome.components.rp2040.boards", None) + assert spec is not None + + from esphome.components.rp2 import boards as rp2_boards + from esphome.components.rp2040 import boards as rp2040_boards + + assert rp2040_boards is rp2_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 + + +# --------------------------------------------------------------------------- +# 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 pin down the rewrite behavior, the warning text, and the +# both-keys-present conflict. + + +def test_resolve_component_aliases_renames_legacy_key( + caplog: pytest.LogCaptureFixture, +) -> None: + """A legacy alias key ``rp2040:`` should be renamed to the canonical + ``rp2:`` and a deprecation warning citing the removal version logged.""" + import logging + + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + CORE.data.pop(_ALIAS_WARNED_KEY, None) # ensure the warning fires + config = {"esphome": {"name": "test"}, "rp2040": {"board": "rpipicow"}} + + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases(config) + + assert "rp2040" not in config + assert config["rp2"] == {"board": "rpipicow"} + assert any( + "'rp2040:' top-level key is deprecated" in record.message + and "rename it to 'rp2:'" in record.message + and "2027.7.0" in record.message + for record in caplog.records + ) + + +def test_resolve_component_aliases_dedupes_warning_within_a_run( + 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.""" + import logging + + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + CORE.data.pop(_ALIAS_WARNED_KEY, None) + with caplog.at_level(logging.WARNING, logger="esphome.config"): + _resolve_component_aliases({"rp2040": {"board": "rpipicow"}}) + _resolve_component_aliases({"rp2040": {"board": "rpipico2w"}}) + + matches = [ + r + for r in caplog.records + if "'rp2040:' top-level key is deprecated" in r.message + ] + assert len(matches) == 1 + + +def test_resolve_component_aliases_rejects_both_keys_present() -> None: + """If the user has BOTH legacy and canonical keys, silently dropping + one would hide a real misconfiguration. Raise instead.""" + import voluptuous as vol + + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = { + "rp2": {"board": "rpipicow"}, + "rp2040": {"board": "rpipicow"}, + } + with pytest.raises(vol.Invalid, match="Both 'rp2040:'"): + _resolve_component_aliases(config) + + +def test_resolve_component_aliases_no_op_when_no_legacy_keys() -> None: + """The pre-pass must be a no-op (no warning, no mutation) for configs + that already use canonical keys.""" + import logging + + from esphome.config import _ALIAS_WARNED_KEY, _resolve_component_aliases + from esphome.core import CORE + + CORE.data.pop(_ALIAS_WARNED_KEY, None) + config = {"esphome": {"name": "test"}, "rp2": {"board": "rpipicow"}} + original = dict(config) + + with caplog_at_warning() as records: + _resolve_component_aliases(config) + + assert config == original + assert not any("deprecated" in r.message for r in records) + _ = logging # silence unused-import in branches that don't read records + + +# Helper context manager — small enough to inline rather than pull in +# caplog for the simple "did anything warn?" case above. +import contextlib # noqa: E402 + + +@contextlib.contextmanager +def caplog_at_warning(): + """Minimal in-test caplog substitute: collect WARNING records on a + dedicated handler attached to ``esphome.config``.""" + import logging + + logger = logging.getLogger("esphome.config") + records: list[logging.LogRecord] = [] + + class _Handler(logging.Handler): + def emit(self, record): # noqa: D401 + records.append(record) + + handler = _Handler(level=logging.WARNING) + logger.addHandler(handler) + prev_level = logger.level + logger.setLevel(logging.WARNING) + try: + yield records + finally: + logger.removeHandler(handler) + logger.setLevel(prev_level) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index eb39ceab2a..9a9aafec43 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -24,6 +24,7 @@ from esphome.__main__ import ( _make_crystal_freq_callback, _redact_with_legacy_fallback, _resolve_network_devices, + _unresolved_default_error, _validate_bootloader_binary, _validate_partition_table_binary, choose_upload_log_host, @@ -32,6 +33,7 @@ from esphome.__main__ import ( command_clean_all, command_config, command_config_hash, + command_dashboard, command_idedata, command_rename, command_run, @@ -92,7 +94,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -157,14 +159,17 @@ def setup_core( CORE.config = config CORE.toolchain = Toolchain.PLATFORMIO - if platform is not None: - CORE.data[KEY_CORE] = {} - CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + # Production always populates CORE.data[KEY_CORE] before upload/logs run + # (the platform validator sets it during read_config, and + # StorageJSON.apply_to_core sets it on the cache fast path), so mirror + # that here. Tests that exercise platform-specific behavior pass a + # platform explicitly; the rest get a platform-agnostic None. + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} if tmp_path is not None: - CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.config_path = tmp_path / f"{name}.yaml" CORE.name = name - CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + CORE.build_path = tmp_path / ".esphome" / "build" / name @pytest.fixture @@ -743,9 +748,7 @@ 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") - with pytest.raises( - EsphomeError, match="All specified devices .* could not be resolved" - ): + with pytest.raises(EsphomeError, match="no 'ota:' platform is configured"): choose_upload_log_host( default="OTA", check_default=None, @@ -765,6 +768,57 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: + """A resolvable device with only ota: fails logs with a missing-api message.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_choose_upload_log_host_logging_no_transport_reports_missing_api() -> None: + """A resolvable device with neither api: nor MQTT logging fails clearly.""" + setup_core(address="192.168.1.100") + + with pytest.raises(EsphomeError, match="no 'api:' component is configured"): + choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + + +def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: + """A .local host with mDNS disabled and no cache keeps the dashboard hint.""" + setup_core( + config={CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}}, + address="esp32-a1s.local", + ) + CORE.dashboard = True + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "could not be resolved" in msg + assert "set 'use_address'" in msg + + +def test_unresolved_default_error_upload_with_ota_is_generic() -> None: + """With ota: present the upload error stays generic, not transport-specific.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + CORE.dashboard = False + + msg = _unresolved_default_error(Purpose.UPLOADING, ["OTA"]) + assert "could not be resolved" in msg + assert "try --device " in msg + + @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" @@ -1172,7 +1226,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( mock_choose_prompt: Mock, ) -> None: """Test interactive mode shows RP2040 BOOTSEL option via picotool.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1195,7 +1249,7 @@ def test_choose_upload_log_host_no_defaults_with_rp2040_bootsel( @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_rp2040_no_device_shows_bootsel_help() -> None: """Test BOOTSEL instructions shown when no RP2040 device found.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1217,7 +1271,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_ota( ) -> None: """Test BOOTSEL tip shown when only OTA options exist for RP2040.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1246,7 +1300,7 @@ def test_choose_upload_log_host_rp2040_bootsel_tip_with_serial_ports( mock_choose_prompt: Mock, ) -> None: """Test BOOTSEL tip shown when serial ports exist but no BOOTSEL device.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1271,7 +1325,7 @@ def test_choose_upload_log_host_rp2040_permission_error_no_options( caplog: pytest.LogCaptureFixture, ) -> None: """Test permission warning shown when BOOTSEL device found but not accessible.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) with ( patch( @@ -1301,7 +1355,7 @@ def test_choose_upload_log_host_rp2040_permission_error_with_ota( ) -> None: """Test permission warning shown with OTA fallback available.""" setup_core( - platform=PLATFORM_RP2040, + platform=PLATFORM_RP2, config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100", ) @@ -1358,7 +1412,7 @@ def test_choose_upload_log_host_rp2040_serial_and_bootsel( mock_choose_prompt: Mock, ) -> None: """Test both serial ports and BOOTSEL option shown for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_ports = [MockSerialPort("/dev/ttyACM0", "RP2040 Serial")] with ( @@ -1611,7 +1665,7 @@ def test_upload_using_esptool_with_file_path( @pytest.mark.parametrize( "platform,device", [ - (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_RP2, "/dev/ttyACM0"), (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform ], ) @@ -1639,11 +1693,34 @@ def test_upload_program_serial_platformio_platforms( mock_upload_using_platformio.assert_called_once_with(config, device) +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_serial_unknown_platform( + mock_import: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Serial upload on an unsupported platform falls through to exit_code 1.""" + setup_core(platform="custom_platform") + # Module has no upload_program handler, so the SERIAL branch is reached. + mock_import.return_value = MagicMock(spec=[]) + mock_get_port_type.return_value = "SERIAL" + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + + def test_upload_using_platformio_creates_signed_bin_for_rp2040( tmp_path: Path, ) -> None: """Test that upload_using_platformio creates firmware.bin.signed for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1679,6 +1756,53 @@ def test_upload_using_platformio_skips_signed_bin_for_non_rp2040( assert result == 0 +def test_upload_using_platformio_skips_signed_bin_when_already_present( + tmp_path: Path, +) -> None: + """The signed-bin copy is idempotent: if ``firmware.bin.signed`` already + exists on the RP2 build path, the upload step must not overwrite it + (and must not fail when the unsigned ``firmware.bin`` is absent).""" + setup_core(platform=PLATFORM_RP2) + + build_dir = tmp_path / "build" + build_dir.mkdir() + # Pre-existing signed bin with distinct content — must be preserved. + signed_bin = build_dir / "firmware.bin.signed" + signed_bin.write_bytes(b"already signed") + # No unsigned firmware.bin on disk — the `is_file()` guard must hold. + firmware_elf = build_dir / "firmware.elf" + firmware_elf.write_bytes(b"elf") + + mock_idedata = MagicMock() + mock_idedata.firmware_elf_path = str(firmware_elf) + + with ( + patch("esphome.platformio.toolchain.get_idedata", return_value=mock_idedata), + patch("esphome.platformio.toolchain.run_platformio_cli_run", return_value=0), + ): + result = upload_using_platformio({}, "/dev/ttyACM0") + + assert result == 0 + # Pre-existing signed bin is untouched. + assert signed_bin.read_bytes() == b"already signed" + + +def test_upload_using_platformio_handles_port_none(tmp_path: Path) -> None: + """The upload step must work without a serial port (PlatformIO picks the + target itself); the ``--upload-port`` flag is only appended when a port + is provided.""" + setup_core(platform=PLATFORM_ESP32) + + with patch( + "esphome.platformio.toolchain.run_platformio_cli_run", return_value=0 + ) as mock_run: + result = upload_using_platformio({}, None) + + assert result == 0 + args = mock_run.call_args.args + assert "--upload-port" not in args + + def test_upload_program_serial_upload_failed( mock_upload_using_esptool: Mock, mock_get_port_type: Mock, @@ -1706,7 +1830,7 @@ def test_upload_program_bootsel( mock_get_port_type: Mock, ) -> None: """Test upload_program with BOOTSEL for RP2040.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 0 @@ -1727,7 +1851,7 @@ def test_upload_program_bootsel_failed( mock_get_port_type: Mock, ) -> None: """Test upload_program when BOOTSEL upload fails.""" - setup_core(platform=PLATFORM_RP2040) + setup_core(platform=PLATFORM_RP2) mock_get_port_type.return_value = "BOOTSEL" mock_upload_using_picotool.return_value = 1 @@ -1744,7 +1868,7 @@ def test_upload_program_bootsel_failed( def test_upload_using_picotool_success(tmp_path: Path) -> None: """Test upload_using_picotool succeeds.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1781,7 +1905,7 @@ def test_upload_using_picotool_success(tmp_path: Path) -> None: def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: """Test upload_using_picotool when ELF file is missing.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1799,7 +1923,7 @@ def test_upload_using_picotool_no_elf(tmp_path: Path) -> None: def test_upload_using_picotool_not_found(tmp_path: Path) -> None: """Test upload_using_picotool when picotool binary not found.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -1819,7 +1943,7 @@ def test_upload_using_picotool_not_found(tmp_path: Path) -> None: def test_upload_using_picotool_permission_error(tmp_path: Path) -> None: """Test upload_using_picotool shows helpful message on permission error.""" - setup_core(platform=PLATFORM_RP2040, tmp_path=tmp_path) + setup_core(platform=PLATFORM_RP2, tmp_path=tmp_path) build_dir = tmp_path / "build" build_dir.mkdir() @@ -3720,6 +3844,45 @@ def test_command_wizard(tmp_path: Path) -> None: mock_wizard.assert_called_once_with(config_file) +def test_command_dashboard_errors_with_device_builder_redirect() -> None: + """The removed dashboard command points users to ESPHome Device Builder.""" + args = MockArgs() + + with pytest.raises(EsphomeError, match="esphome-device-builder"): + command_dashboard(args) + + +@pytest.mark.parametrize( + "argv", + [ + ["esphome", "dashboard"], + ["esphome", "dashboard", "/config"], + # Legacy flags must be accepted so old invocations reach the redirect + # instead of failing on argparse "unrecognized arguments". + ["esphome", "dashboard", "--port", "6052", "/config"], + ["esphome", "dashboard", "--username", "u", "--password", "p", "--open-ui"], + [ + "esphome", + "dashboard", + "--address", + "0.0.0.0", + "--socket", + "/x", + "--ha-addon", + ], + ], +) +def test_run_esphome_dashboard_redirects_to_device_builder( + argv: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`esphome dashboard` still parses but fails with the redirect message.""" + result = run_esphome(argv) + + assert result == 1 + assert "esphome-device-builder" in caplog.text + + def test_command_config_hash( tmp_path: Path, capfd: CaptureFixture[str], @@ -6290,6 +6453,44 @@ def test_command_run_defaults_subscribe_states_true( ) +def test_command_run_rp2040_bootsel_redetects_serial_port() -> None: + """After a BOOTSEL upload (no device) on RP2040, command_run waits for and + picks up the newly enumerated serial port before showing logs.""" + setup_core( + config={"logger": {}, CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}}, + platform=PLATFORM_RP2, + ) + + args = MockArgs() + args.no_logs = False + args.device = None + + new_port = MockSerialPort("/dev/ttyACM0", "RP2040 Serial") + + with ( + patch("esphome.__main__.write_cpp", return_value=0), + patch("esphome.__main__.compile_program", return_value=0), + patch( + "esphome.__main__.choose_upload_log_host", + side_effect=[[], ["/dev/ttyACM0"]], + ) as mock_choose, + patch("esphome.__main__.upload_program", return_value=(0, None)), + patch( + "esphome.__main__.get_serial_ports", + side_effect=[[], [new_port]], + ), + patch("esphome.__main__._wait_for_serial_port") as mock_wait, + patch("esphome.__main__.show_logs", return_value=0) as mock_show_logs, + ): + result = command_run(args, CORE.config) + + assert result == 0 + mock_wait.assert_called_once_with(known_ports=set()) + # The re-detected serial port is used as the preferred logging device. + assert mock_choose.call_args_list[-1].kwargs["default"] == "/dev/ttyACM0" + mock_show_logs.assert_called_once_with(CORE.config, args, ["/dev/ttyACM0"]) + + def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None: """Under the native ESP-IDF toolchain, idedata is emitted as JSON.""" setup_core() diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 9652ad08eb..bb5bc8c064 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -1,5 +1,6 @@ """Tests for esphome.components.nrf52.framework helpers.""" +import hashlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -7,15 +8,32 @@ from unittest.mock import patch import pytest from esphome.components.nrf52.framework import ( - _TOOLCHAIN_VERSION, + _REQUIREMENTS, + TOOLCHAIN_VERSION, _get_toolchain_platform_info, check_and_install, + get_sdk_nrf_tools_path, ) from esphome.config_validation import Version from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError +@pytest.fixture(autouse=True) +def _isolate_sdk_nrf_install_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Pin the sdk-nrf install root to a tmp dir for every test. + + The default location is the OS user cache dir, so without this any test + that builds framework paths or pre-creates the install dir would touch + the real ``~/.cache/esphome`` on the developer's machine. Tests that need + to exercise the override or default-resolution logic clear/override the + env themselves. + """ + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(tmp_path / "sdk_nrf_install")) + + @pytest.mark.parametrize( ("system", "machine", "expected"), [ @@ -52,12 +70,15 @@ _TEST_SDK_VERSION = "2.9.0" def nrf52_dirs(setup_core: Path) -> SimpleNamespace: """Populate CORE and pre-create SDK directories so sentinel.touch() succeeds.""" CORE.data[KEY_CORE] = {KEY_FRAMEWORK_VERSION: Version.parse(_TEST_SDK_VERSION)} - tools = CORE.data_dir / "sdk-nrf" + tools = get_sdk_nrf_tools_path() python_env = tools / "penvs" / f"v{_TEST_SDK_VERSION}" framework = tools / "frameworks" / f"v{_TEST_SDK_VERSION}" - toolchain_dir = tools / "toolchains" / _TOOLCHAIN_VERSION + 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, @@ -94,6 +115,12 @@ def mock_nrf52_ops(): # --------------------------------------------------------------------------- +def _mark_venv_ready(python_env: Path) -> None: + """Write the venv sentinel with the current requirements hash.""" + requirements_hash = hashlib.sha256(_REQUIREMENTS.read_bytes()).hexdigest() + (python_env / ".ready").write_text(requirements_hash, encoding="utf-8") + + class TestCheckAndInstall: def test_all_installed_skips_all_steps( self, @@ -101,7 +128,8 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """All three sentinels present → nothing downloaded or compiled.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) + (nrf52_dirs.python_env / ".zephyr_reqs_ready").touch() (nrf52_dirs.framework / ".ready").touch() (nrf52_dirs.toolchain / ".ready").touch() @@ -121,11 +149,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() @@ -135,14 +165,15 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv ready but framework missing → skip venv creation, run SDK init+update.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) 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, @@ -150,25 +181,27 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Venv and framework ready → only toolchain downloaded and extracted.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) + (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( @@ -177,7 +210,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west init raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) mock_nrf52_ops.run_command_ok.return_value = False with pytest.raises(EsphomeError, match="Can't initialize"): @@ -189,7 +222,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """Failing west update raises EsphomeError.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) # init succeeds, update fails mock_nrf52_ops.run_command_ok.side_effect = [True, False] @@ -202,7 +235,7 @@ class TestCheckAndInstall: mock_nrf52_ops: SimpleNamespace, ) -> None: """download_from_mirrors receives VERSION + platform triple from _get_toolchain_platform_info.""" - (nrf52_dirs.python_env / ".ready").touch() + _mark_venv_ready(nrf52_dirs.python_env) (nrf52_dirs.framework / ".ready").touch() with patch( @@ -213,7 +246,50 @@ class TestCheckAndInstall: args, _ = mock_nrf52_ops.download_from_mirrors.call_args substitutions = args[1] - assert substitutions["VERSION"] == _TOOLCHAIN_VERSION + assert substitutions["VERSION"] == TOOLCHAIN_VERSION assert substitutions["sysname"] == "linux" assert substitutions["machine"] == "x86_64" assert substitutions["extension"] == "tar.xz" + + +# --------------------------------------------------------------------------- +# get_sdk_nrf_tools_path tests +# --------------------------------------------------------------------------- + + +def testget_tools_path_env_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + override = tmp_path / "custom" / "sdk-nrf" + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(override)) + assert get_sdk_nrf_tools_path() == override.resolve() + + +@pytest.mark.parametrize("value", ["", " "]) +def testget_tools_path_blank_env_falls_back_to_default( + value: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """A blank ESPHOME_SDK_NRF_PREFIX is treated as unset, not as CWD. + + Path("") would resolve to the working directory, which clean-all could + then delete by accident. + """ + import platformdirs + + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected + + +def testget_tools_path_default_is_global_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import platformdirs + + monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False) + expected = ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" + ).resolve() + assert get_sdk_nrf_tools_path() == expected diff --git a/tests/unit_tests/test_nrf52_upload.py b/tests/unit_tests/test_nrf52_upload.py new file mode 100644 index 0000000000..a60e23a337 --- /dev/null +++ b/tests/unit_tests/test_nrf52_upload.py @@ -0,0 +1,292 @@ +"""Tests for esphome.components.nrf52 upload_program and run_compile.""" + +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.components.nrf52.const import BOOTLOADER_ADAFRUIT_NRF52_SD140_V7 +from esphome.components.zephyr.const import ( + KEY_BOARD, + KEY_BOOTLOADER, + KEY_EXTRA_BUILD_FILES, + KEY_KCONFIG, + KEY_OVERLAY, + KEY_PM_STATIC, + KEY_PRJ_CONF, + KEY_USER, + KEY_ZEPHYR, +) +import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_NRF52, + Toolchain, +) +from esphome.core import CORE, EsphomeError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_nrf52_core( + bootloader: str = BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, + toolchain: Toolchain = Toolchain.SDK_NRF, + build_path: Path | None = None, +) -> None: + CORE.name = "test_device" + if build_path is not None: + CORE.build_path = build_path + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2), + } + CORE.toolchain = toolchain + CORE.data[KEY_ZEPHYR] = { + KEY_BOARD: "adafruit_feather_nrf52840", + KEY_BOOTLOADER: bootloader, + KEY_PRJ_CONF: {}, + KEY_OVERLAY: {"": ""}, + KEY_EXTRA_BUILD_FILES: {}, + KEY_PM_STATIC: [], + KEY_USER: {}, + KEY_KCONFIG: "", + } + + +def _make_paths(tmp_path: Path) -> dict: + return { + "python_executable": tmp_path / "penv" / "python", + "framework_path": tmp_path / "framework", + } + + +# --------------------------------------------------------------------------- +# Config-reconstruction guard +# --------------------------------------------------------------------------- + + +class TestUploadProgramConfigGuard: + def test_missing_platform_config_raises(self, setup_core: Path) -> None: + """upload_program raises EsphomeError when the platform config section is absent.""" + from esphome.components.nrf52 import upload_program + + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_NRF52, + KEY_TARGET_FRAMEWORK: KEY_ZEPHYR, + } + # KEY_ZEPHYR absent → reconstruction branch is entered + assert KEY_ZEPHYR not in CORE.data + + with pytest.raises(EsphomeError, match="platform configuration"): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# PYOCD upload path +# --------------------------------------------------------------------------- + + +class TestUploadProgramPyocd: + def test_pyocd_assembles_west_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """West flash command must include --runner pyocd and the build dir.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch("esphome.components.nrf52.get_build_paths", return_value=paths), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch( + "esphome.components.nrf52.run_command_ok", return_value=True + ) as mock_run, + ): + result = upload_program(config={}, args=None, host="PYOCD") + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert str(paths["python_executable"]) == cmd[0] + assert "west" in cmd + assert "flash" in cmd + assert "--runner" in cmd + assert "pyocd" in cmd + assert "-d" in cmd + assert str(build_dir) in cmd + + def test_pyocd_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed west flash must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + patch("esphome.components.nrf52.run_command_ok", return_value=False), + pytest.raises(EsphomeError, match="pyocd"), + ): + upload_program(config={}, args=None, host="PYOCD") + + +# --------------------------------------------------------------------------- +# Serial DFU upload path +# --------------------------------------------------------------------------- + + +def _enter_serial_dfu_patches( + stack: ExitStack, host: str, tmp_path: Path, paths: dict +) -> MagicMock: + """Enter all context managers needed for the serial DFU happy path. + + Returns the mock for ``run_command_ok`` so callers can inspect calls. + comports() returns [] on the first call (port disappeared) and a list + containing the host on every subsequent call (port reappeared). Patches + are applied directly on the real pyserial module attributes so they are + visible to the deferred ``import serial[.tools.list_ports] as _x`` + statements inside upload_program. + """ + import serial + import serial.tools.list_ports + + from esphome.upload_targets import PortType + + _comports_calls = [0] + + def _comports(): + _comports_calls[0] += 1 + if _comports_calls[0] == 1: + return [] # port disappeared → disappear loop breaks + return [MagicMock(device=host)] # port back → reappear loop breaks + + stack.enter_context( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL) + ) + stack.enter_context(patch("esphome.__main__.check_permissions")) + stack.enter_context(patch("esphome.components.nrf52.check_and_install")) + stack.enter_context( + patch("esphome.components.nrf52.get_build_paths", return_value=paths) + ) + stack.enter_context( + patch("esphome.components.nrf52.get_build_env", return_value={}) + ) + stack.enter_context(patch("time.sleep")) + # Patch directly on the real pyserial module so the deferred imports inside + # upload_program see our mocks regardless of how sys.modules is cached. + stack.enter_context(patch.object(serial, "Serial")) + stack.enter_context( + patch.object(serial.tools.list_ports, "comports", side_effect=_comports) + ) + return stack.enter_context( + patch("esphome.components.nrf52.run_command_ok", return_value=True) + ) + + +class TestUploadProgramSerialDfu: + def test_unsupported_bootloader_raises( + self, setup_core: Path, tmp_path: Path + ) -> None: + """An unknown bootloader must raise EsphomeError before touching the port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core( + bootloader="unknown_bootloader", build_path=tmp_path / "build" + ) + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + pytest.raises(EsphomeError, match="Not implemented"), + ): + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_missing_firmware_raises(self, setup_core: Path, tmp_path: Path) -> None: + """Missing firmware.zip must raise EsphomeError before opening the serial port.""" + from esphome.components.nrf52 import upload_program + from esphome.upload_targets import PortType + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + + with ( + patch("esphome.upload_targets.get_port_type", return_value=PortType.SERIAL), + patch("esphome.__main__.check_permissions"), + patch("esphome.components.nrf52.check_and_install"), + patch( + "esphome.components.nrf52.get_build_paths", + return_value=_make_paths(tmp_path), + ), + patch("esphome.components.nrf52.get_build_env", return_value={}), + pytest.raises(EsphomeError, match="Firmware not found"), + ): + # firmware.zip does not exist on disk → is_file() returns False + upload_program(config={}, args=None, host="/dev/ttyACM0") + + def test_serial_dfu_assembles_nordicsemi_command( + self, setup_core: Path, tmp_path: Path + ) -> None: + """Nordicsemi DFU command must include pkg path, port, and --singlebank.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + result = upload_program(config={}, args=None, host=host) + + assert result is True + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "nordicsemi.__main__" in cmd + assert "dfu" in cmd + assert "serial" in cmd + assert "-pkg" in cmd + assert str(dfu_package) in cmd + assert "-p" in cmd + assert host in cmd + assert "--singlebank" in cmd + + def test_serial_dfu_failure_raises(self, setup_core: Path, tmp_path: Path) -> None: + """A failed nordicsemi DFU must raise EsphomeError.""" + from esphome.components.nrf52 import upload_program + + _setup_nrf52_core(build_path=tmp_path / "build") + CORE.config_path = tmp_path / "test.yaml" + paths = _make_paths(tmp_path) + build_dir = CORE.relative_pioenvs_path(CORE.name) + dfu_package = build_dir / "firmware.zip" + dfu_package.parent.mkdir(parents=True, exist_ok=True) + dfu_package.touch() + + host = "/dev/ttyACM0" + with ExitStack() as stack: + mock_run = _enter_serial_dfu_patches(stack, host, tmp_path, paths) + mock_run.return_value = False + with pytest.raises(EsphomeError, match="serial DFU upload failed"): + upload_program(config={}, args=None, host=host) diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py new file mode 100644 index 0000000000..03360eab37 --- /dev/null +++ b/tests/unit_tests/test_platformio_library.py @@ -0,0 +1,231 @@ +"""Tests for the toolchain-agnostic PlatformIO library converter. + +Covers the shared download/parse/resolve/dependency-walk paths in +``esphome.platformio.library`` directly (the ESP-IDF and Zephyr backends are +exercised in their own test modules).""" + +import json +import logging +from pathlib import Path + +import pytest + +from esphome.core import Library +import esphome.platformio.library as lib +from esphome.platformio.library import ( + ConvertedLibrary, + GitSource, + InvalidLibrary, + LibraryBackend, + Source, + URLSource, + _resolve_registry_version, + check_library_data, + convert_libraries, +) + + +def _backend(emit=lambda component: None) -> LibraryBackend: + return LibraryBackend( + platform="espressif32", framework="espidf", emit=emit, cache_key="idf" + ) + + +def test_check_library_data_accepts_wildcards(): + check_library_data({"platforms": "*", "frameworks": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_missing_frameworks(): + check_library_data({"platforms": "*"}, "espressif32", "espidf") + + +def test_check_library_data_accepts_empty_manifest(): + check_library_data({}, "espressif32", "espidf") + + +def test_check_library_data_accepts_matching_platform(): + check_library_data( + {"platforms": "espressif32", "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_accepts_matching_framework(): + check_library_data( + {"platforms": "*", "frameworks": "espidf"}, "espressif32", "espidf" + ) + + +def test_check_library_data_rejects_unsupported_platform(): + with pytest.raises(InvalidLibrary): + check_library_data( + {"platforms": ["other"], "frameworks": "*"}, "espressif32", "espidf" + ) + + +def test_check_library_data_warns_on_framework_mismatch( + caplog: pytest.LogCaptureFixture, +): + # Framework mismatch is a warning, not a hard skip: the library is still + # included so manifests that only list "arduino" (but compile fine under the + # target framework) can be used without forking them. + with caplog.at_level(logging.WARNING, logger="esphome.platformio.library"): + check_library_data( + {"name": "lib", "platforms": "*", "frameworks": ["other"]}, + "espressif32", + "espidf", + ) + assert "do not include 'espidf'" in caplog.text + + +def test_source_download_not_implemented(): + with pytest.raises(NotImplementedError): + Source().download("x") + + +def test_gitsource_str_includes_ref_when_present(): + assert str(GitSource("http://git/repo.git", "main")) == "http://git/repo.git#main" + assert str(GitSource("http://git/repo.git", None)) == "http://git/repo.git" + + +def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch): + monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None) + dl_calls: list[list[str]] = [] + monkeypatch.setattr( + lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls) + ) + + def fake_extract(fileobj, path): + Path(path).mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(lib, "archive_extract_all", fake_extract) + + src = URLSource("http://example.test/lib.tar.gz") + out = src.download("mylib") + + assert (out / ".esphome_extracted").is_file() + assert dl_calls == [["http://example.test/lib.tar.gz"]] + + # The completion marker means a second download is skipped (cache hit). + out2 = src.download("mylib") + assert out2 == out + assert len(dl_calls) == 1 + + +def test_resolve_registry_version_raises_without_pkg_file(monkeypatch): + registry = lib._make_registry_client() + monkeypatch.setattr( + registry, + "fetch_registry_package", + lambda spec: { + "owner": {"username": spec.owner or "owner"}, + "name": spec.name, + "versions": [{"name": "1.0.0", "files": [{}]}], + }, + ) + # A best version exists but none of its files is a compatible package. + monkeypatch.setattr( + registry, "pick_best_registry_version", lambda versions: versions[0] + ) + monkeypatch.setattr(registry, "pick_compatible_pkg_file", lambda files: None) + monkeypatch.setattr(lib, "_make_registry_client", lambda: registry) + + with pytest.raises(RuntimeError, match="No package file"): + _resolve_registry_version("owner", "pkg", set()) + + +def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properties=()): + """Fake ConvertedLibrary.download to materialize canned manifests on disk.""" + + def fake_download(self, force=False, salt="", namespace=""): + self.path = tmp_path / self.get_sanitized_name().replace("/", "__") + self.path.mkdir(parents=True, exist_ok=True) + if self.name in properties: + (self.path / "library.properties").write_text(manifests[self.name]) + else: + (self.path / "library.json").write_text(json.dumps(manifests[self.name])) + + monkeypatch.setattr(ConvertedLibrary, "download", fake_download) + monkeypatch.setattr( + lib, + "_resolve_registry_version", + lambda owner, pkgname, requirements: ( + owner, + pkgname, + "1.0.0", + f"http://x/{pkgname}.tar.gz", + ), + ) + + +def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch): + # A manifest provided as library.properties (Arduino style) instead of + # library.json must still be parsed and converted. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": "name=A\nversion=1.0\n"}, + properties=("esphome/A",), + ) + + emitted: list[ConvertedLibrary] = [] + top = convert_libraries( + [Library("esphome/A", "1.0.0", None)], _backend(emitted.append) + ) + + assert [c.name for c in top] == ["esphome/A"] + assert top[0].data["name"] == "A" + assert emitted[0].data["version"] == "1.0" + + +def test_convert_libraries_skips_dependency_without_version(tmp_path, monkeypatch): + # A dependency entry lacking a version is malformed and silently skipped. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + {"esphome/A": {"name": "A", "dependencies": [{"name": "C"}]}}, + ) + + # No version on the top-level spec exercises the "no requirement" path too. + top = convert_libraries([Library("esphome/A", None, None)], _backend()) + + assert top[0].dependencies == [] + + +def test_convert_libraries_handles_unparsable_dependency_version(tmp_path, monkeypatch): + # If the git/archive URL probe (urlparse) raises on a malformed value, the + # dependency is still kept and treated as a plain version spec. + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + # An unterminated IPv6 URL makes urlparse raise ValueError. + "dependencies": [{"name": "C", "version": "http://[::1"}], + }, + "C": {"name": "C"}, + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert [d.name for d in top[0].dependencies] == ["C"] + + +def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): + # A dependency that declares an incompatible platform is skipped (the + # top-level library still builds). + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "C", "version": "1.0", "platforms": ["avr"]}], + } + }, + ) + + top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) + + assert top[0].dependencies == [] diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py new file mode 100644 index 0000000000..677eeee7f2 --- /dev/null +++ b/tests/unit_tests/test_preferences.py @@ -0,0 +1,149 @@ +"""Tests for esphome.preferences storage backend selection.""" + +import pytest + +from esphome import preferences +from esphome.components.esp32 import KEY_ESP32 +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C61, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_STORAGE, + KEY_CORE, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE + + +def _set_platform(platform: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + +def _set_esp32(variant: str) -> None: + _set_platform(PLATFORM_ESP32) + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + + +def _validate(value: dict): + return cv.Schema(preferences.storage_schema())(value) + + +def _define_names() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_is_in_flash() -> None: + _set_platform(PLATFORM_ESP8266) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + # The RTC storage define is ESP32-specific. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_is_in_flash_esp32_rtc_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.is_in_flash(preferences.STORAGE_FLASH) is True + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + assert preferences.is_in_flash(preferences.STORAGE_RTC) is False + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +def test_request_rtc_storage_esp32_only() -> None: + _set_platform(PLATFORM_ESP8266) + preferences.request_rtc_storage() + # ESP8266 always has its RTC backend; no define is needed or emitted. + assert "USE_ESP32_RTC_PREFERENCES" not in _define_names() + + +def test_request_rtc_storage_esp32_emits_define() -> None: + _set_esp32(VARIANT_ESP32) + preferences.request_rtc_storage() + assert "USE_ESP32_RTC_PREFERENCES" in _define_names() + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_validate_rtc_storage_accepted(variant: str) -> None: + _set_esp32(variant) + assert preferences.validate_rtc_storage(True) is True + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + # Tolerated no-op: the ESP8266 backend always has RTC storage. + assert preferences.validate_rtc_storage(True) is True + # But it cannot be disabled, so an explicit false is an error. + with pytest.raises(cv.Invalid, match="always enabled on ESP8266"): + preferences.validate_rtc_storage(False) + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_validate_rtc_storage_rejected_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + # Disabling it is always fine. + assert preferences.validate_rtc_storage(False) is False + + +def test_validate_rtc_storage_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_rtc_storage(True) + + +@pytest.mark.parametrize( + ("platform", "expected"), + [ + # Defaults preserve each platform's historic behavior. + (PLATFORM_ESP8266, preferences.STORAGE_RTC), + (PLATFORM_RP2040, preferences.STORAGE_FLASH), + ], +) +def test_default_storage_per_platform(platform: str, expected: str) -> None: + _set_platform(platform) + assert _validate({})[CONF_STORAGE] == expected + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C2]) +def test_default_storage_esp32_is_flash(variant: str) -> None: + # ESP32 defaults to flash on every variant, including those without RTC memory. + _set_esp32(variant) + assert _validate({})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +def test_rtc_allowed_on_esp8266() -> None: + _set_platform(PLATFORM_ESP8266) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_allowed_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert _validate({CONF_STORAGE: "rtc"})[CONF_STORAGE] == preferences.STORAGE_RTC + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_rejected_on_esp32_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_rtc_rejected_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + _validate({CONF_STORAGE: "rtc"}) + + +def test_flash_allowed_on_unsupported_platform() -> None: + _set_platform(PLATFORM_RP2040) + assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py index 7ba56b05f4..01683507c1 100644 --- a/tests/unit_tests/test_storage_json.py +++ b/tests/unit_tests/test_storage_json.py @@ -352,6 +352,7 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: mock_core.web_port = None mock_core.target_platform = "esp8266" mock_core.is_esp32 = False + mock_core.is_nrf52 = False mock_core.build_path = "/build" mock_core.firmware_bin = "/build/firmware.bin" mock_core.loaded_integrations = set() @@ -366,6 +367,34 @@ def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: assert result.toolchain is None +def test_storage_json_from_esphome_core_nrf52(setup_core: Path) -> None: + """Test from_esphome_core captures the framework version on nRF52.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + mock_core = MagicMock() + mock_core.name = "nrf_device" + mock_core.friendly_name = "nRF Device" + mock_core.comment = None + mock_core.address = "nrf.local" + mock_core.web_port = None + mock_core.target_platform = "nrf52" + mock_core.is_esp32 = False + mock_core.is_nrf52 = True + mock_core.data = {KEY_CORE: {KEY_FRAMEWORK_VERSION: cv.Version(2, 9, 2)}} + mock_core.build_path = "/build/nrf_device" + mock_core.firmware_bin = "/build/nrf_device/firmware.bin" + mock_core.loaded_integrations = set() + mock_core.loaded_platforms = set() + mock_core.config = {} + mock_core.target_framework = "zephyr" + mock_core.toolchain = None + + result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) + + assert result.target_platform == "NRF52" + assert result.framework_version == "2.9.2" + + def test_storage_json_load_valid_file(tmp_path: Path) -> None: """Test StorageJSON.load with valid JSON file.""" storage_data = { @@ -787,6 +816,73 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: assert result.esphome_version == "1.14.0" # Should map to esphome_version +def _make_nrf52_storage( + framework_version: str | None = None, +) -> storage_json.StorageJSON: + return storage_json.StorageJSON( + storage_version=1, + name="dev", + friendly_name=None, + comment=None, + esphome_version="2024.1.0", + src_version=1, + address="dev.local", + web_port=None, + target_platform="NRF52", + build_path=Path("/build"), + firmware_bin_path=Path("/build/zephyr/zephyr.bin"), + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + framework="zephyr", + core_platform="nrf52", + framework_version=framework_version, + ) + + +def test_storage_json_nrf52_framework_version_round_trip(setup_core: Path) -> None: + """Sidecar framework_version restores CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION].""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + storage = _make_nrf52_storage("2.9.2") + path = setup_core / "storage.json" + path.write_text(storage.to_json()) + + assert json.loads(path.read_text())["framework_version"] == "2.9.2" + + loaded = storage_json.StorageJSON.load(path) + assert loaded is not None + assert loaded.framework_version == "2.9.2" + + loaded.apply_to_core() + assert CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] == cv.Version(2, 9, 2) + + +def test_storage_json_nrf52_apply_to_core_without_framework_version( + setup_core: Path, +) -> None: + """Older sidecars lacking framework_version don't populate KEY_FRAMEWORK_VERSION.""" + from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION + + loaded = _make_nrf52_storage(framework_version=None) + assert loaded.framework_version is None + + loaded.apply_to_core() + assert KEY_FRAMEWORK_VERSION not in CORE.data[KEY_CORE] + + +def test_storage_json_nrf52_apply_to_core_raises_on_invalid_framework_version( + setup_core: Path, +) -> None: + """A malformed version string fails with an actionable error at parse time.""" + from esphome.core import EsphomeError + + loaded = _make_nrf52_storage(framework_version="not-a-version") + + with pytest.raises(EsphomeError, match="clean the build"): + loaded.apply_to_core() + + def test_storage_json_load_area(tmp_path: Path) -> None: """``area`` round-trips through load; absence loads as None.""" file_path = tmp_path / "with_area.json" diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py index 0ce89230d8..244e4eb5a1 100644 --- a/tests/unit_tests/test_wizard.py +++ b/tests/unit_tests/test_wizard.py @@ -11,6 +11,7 @@ from esphome.components.bk72xx.boards import BK72XX_BOARD_PINS from esphome.components.esp32.boards import ESP32_BOARD_PINS from esphome.components.esp8266.boards import ESP8266_BOARD_PINS from esphome.components.ln882x.boards import LN882X_BOARD_PINS +from esphome.components.rp2.boards import RP2_BOARD_PINS from esphome.components.rtl87xx.boards import RTL87XX_BOARD_PINS from esphome.core import CORE import esphome.wizard as wz @@ -300,6 +301,31 @@ def test_wizard_write_defaults_platform_from_board_rtl87xx( assert "rtl87xx:" in generated_config +def test_wizard_write_defaults_platform_from_board_rp2( + default_config: dict[str, Any], tmp_path: Path, monkeypatch: MonkeyPatch +): + """ + If the platform is not explicitly set, use "RP2" when the board is in + the RP2 boards list. The generated config must use the canonical + ``rp2:`` top-level key (not the deprecated ``rp2040:`` alias). + """ + # Given + del default_config["platform"] + default_config["board"] = [*RP2_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + monkeypatch.setattr(CORE, "config_path", tmp_path.parent) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "rp2:" in generated_config + # Guard against regressing to the legacy alias key. + assert "rp2040:" not in generated_config + + def test_safe_print_step_prints_step_number_and_description(monkeypatch: MonkeyPatch): """ The safe_print_step function prints the step number and the passed description @@ -450,6 +476,34 @@ def test_wizard_accepts_default_answers_esp32( assert retval == 0 +def test_wizard_accepts_default_answers_bk72xx( + tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] +): + """ + The wizard should accept the given default answers for bk72xx. The + libretiny branch also exercises the False side of the + ``elif platform == "RP2":`` checks in the platform / board-link + elif chain (without this, those branches show as partial coverage + because only the rpipico interactive test reaches them with platform + == "RP2"). + """ + # Given + wizard_answers[1] = "BK72XX" + wizard_answers[2] = next(iter(BK72XX_BOARD_PINS)) + config_file = tmp_path / "test.yaml" + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None, end=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 0 + + def test_wizard_offers_better_node_name( tmp_path: Path, monkeypatch: MonkeyPatch, wizard_answers: list[str] ): @@ -612,7 +666,7 @@ def test_wizard_accepts_rpipico_board(tmp_path: Path, monkeypatch: MonkeyPatch): # Given wizard_answers_rp2040 = [ "test-node", # Name of the node - "RP2040", # platform + "RP2", # platform (canonical name; ``RP2040`` was the legacy alias) "rpipico", # board (no WiFi support) ] config_file = tmp_path / "test.yaml" diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index c8cf68ff3e..46e60ebd8e 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -18,7 +18,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ) from esphome.core import EsphomeError @@ -67,15 +67,34 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: want to verify the PIO-cleanup branch (e.g. test_clean_all, test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. + + Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to + nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the + same reason: ``clean_all`` removes the machine-global toolchain installs + and their default cache root, which otherwise resolve to the real + ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" + idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" + sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent" + cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( str(pio_root / option) if section == "platformio" else "" ) - with patch( - "platformio.project.config.ProjectConfig.get_instance", - return_value=mock_cfg, + with ( + patch( + "platformio.project.config.ProjectConfig.get_instance", + return_value=mock_cfg, + ), + patch.dict( + "os.environ", + { + "ESPHOME_ESP_IDF_PREFIX": str(idf_root), + "ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root), + }, + ), + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), ): yield @@ -319,7 +338,7 @@ def test_storage_should_not_update_cmake_cache_when_nothing_changes( @pytest.mark.parametrize( "core_platform", - [PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_RTL87XX], + [PLATFORM_ESP8266, PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_RTL87XX], ) def test_storage_should_not_update_cmake_cache_for_non_esp32( create_storage: Callable[..., StorageJSON], @@ -990,6 +1009,79 @@ def test_clean_all_with_yaml_file( assert str(build_dir) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_idf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native ESP-IDF install dir.""" + idf_install = tmp_path / "idf_install" + (idf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_ESP_IDF_PREFIX", str(idf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not idf_install.exists() + assert str(idf_install.resolve()) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_sdk_nrf_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native sdk-nrf install dir.""" + sdk_nrf_install = tmp_path / "sdk_nrf_install" + (sdk_nrf_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", str(sdk_nrf_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not sdk_nrf_install.exists() + assert str(sdk_nrf_install.resolve()) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_all_removes_default_cache_root( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the default cache root (stale/orphaned installs).""" + cache_root = tmp_path / "cache_root" + (cache_root / "some-old-toolchain").mkdir(parents=True) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + from esphome.writer import clean_all + + with ( + patch("platformdirs.user_cache_dir", return_value=str(cache_root)), + caplog.at_level("INFO"), + ): + clean_all([str(config_dir)]) + + assert not cache_root.exists() + assert str(cache_root.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_with_yaml_build_path( mock_core: MagicMock, diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 6be090b869..5c38fce105 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -1349,6 +1349,57 @@ def test_sensitive_str__is_a_str_subclass() -> None: assert value == "hunter2" +def test_dump_path_without_relative_to_is_unchanged() -> None: + """Test that Path values dump as str(path) when relative_to is not given.""" + path = Path("some") / "dir" / "file.ttf" + output = yaml_util.dump({"file": path}) + assert output.strip() == f"file: {path}" + + +def test_dump_path_relative_to_anchor_dir() -> None: + """Test that Path values under relative_to dump as relative POSIX paths.""" + anchor = Path("/config/esphome").absolute() + data = {"file": anchor / "fonts" / "arial.ttf"} + output = yaml_util.dump(data, relative_to=anchor) + assert output.strip() == "file: fonts/arial.ttf" + + +def test_dump_path_outside_anchor_dir_walks_up() -> None: + """Test that Path values outside relative_to walk up with ".." segments.""" + anchor = Path("/config/esphome").absolute() + outside = Path("/config/fonts/file.ttf").absolute() + output = yaml_util.dump({"file": outside}, relative_to=anchor) + assert output.strip() == "file: ../fonts/file.ttf" + + +def test_dump_path_with_dotdot_segments_is_normalized() -> None: + """Test that ".." segments do not defeat relativization. + + A path like /config/other/../esphome/fonts/x.ttf is under the anchor + once normalized, so it must dump as a plain relative path. + """ + anchor = Path("/config/esphome").absolute() + path = Path("/config/other/../esphome/fonts/x.ttf").absolute() + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: fonts/x.ttf" + + +def test_dump_path_dotdot_reference_outside_anchor() -> None: + """Test the relative_config_path("../...") shape stays relative.""" + anchor = Path("/config/esphome").absolute() + path = anchor / ".." / "shared" / "font.ttf" + output = yaml_util.dump({"file": path}, relative_to=anchor) + assert output.strip() == "file: ../shared/font.ttf" + + +def test_dump_relative_to_does_not_leak_between_calls() -> None: + """Test that the relative_to flag is scoped to a single dump call.""" + anchor = Path("/config/esphome").absolute() + path = anchor / "fonts" / "arial.ttf" + assert "fonts/arial.ttf" in yaml_util.dump({"file": path}, relative_to=anchor) + assert yaml_util.dump({"file": path}).strip() == f"file: {path}" + + def test_dump__redacts_sensitive_str_by_default() -> None: out = yaml_util.dump({"password": SensitiveStr("hunter2")}) assert "\\033[8mhunter2\\033[28m" in out @@ -1395,3 +1446,48 @@ def test_dump__redaction_flag_does_not_leak_between_calls() -> None: assert "\\033[8m" in redacted assert "\\033[8m" not in raw assert "\\033[8m" in redacted_again + + +@pytest.fixture(autouse=True) +def clear_dropped_merge_keys() -> None: + """Reset the dropped-merge-key queue between tests.""" + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + yield + core.CORE.data.pop(yaml_util._MERGE_WARNINGS_KEY, None) + + +def test_merge_include_records_dropped_keys(tmp_path: Path) -> None: + """A `<<` merge that overlaps an existing key records it (shallow first-wins).""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("api:\n password: secret\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + # First definition wins; the second `api` block is dropped entirely. + assert result["api"] == {"reboot_timeout": "5min"} + + dropped = yaml_util.take_dropped_merge_keys() + assert len(dropped) == 1 + key, location = dropped[0] + assert key == "api" + assert "b.yaml" in location + # Queue is drained after being taken. + assert yaml_util.take_dropped_merge_keys() == [] + + +def test_merge_include_no_overlap_records_nothing(tmp_path: Path) -> None: + """A `<<` merge with distinct top-level keys drops nothing.""" + (tmp_path / "a.yaml").write_text("api:\n reboot_timeout: 5min\n") + (tmp_path / "b.yaml").write_text("logger:\n level: DEBUG\n") + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("<<: !include a.yaml\n<<: !include b.yaml\n") + + with patch.object(core.CORE, "config_path", test_yaml): + result = yaml_util.load_yaml(test_yaml) + + assert result["api"] == {"reboot_timeout": "5min"} + assert result["logger"] == {"level": "DEBUG"} + assert yaml_util.take_dropped_merge_keys() == [] diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py new file mode 100644 index 0000000000..0ba3577fa7 --- /dev/null +++ b/tests/unit_tests/test_zephyr_library.py @@ -0,0 +1,117 @@ +"""Tests for the Zephyr backend of the shared PlatformIO library converter.""" + +from pathlib import Path + +import pytest + +import esphome.components.zephyr.library as zlib +from esphome.components.zephyr.library import ( + generate_cmakelists_txt, + generate_module_yml, + generate_zephyr_modules, +) +from esphome.core import EsphomeError, Library +from esphome.platformio.library import ConvertedLibrary, URLSource + + +def _make_component(path: Path, name: str = "mylib") -> ConvertedLibrary: + c = ConvertedLibrary(name, "1.0", source=URLSource("http://dummy")) + c.path = path + return c + + +def test_generate_module_yml_uses_sanitized_name(): + c = ConvertedLibrary("owner/My Lib", "1.0", source=URLSource("http://dummy")) + out = generate_module_yml(c) + # "/" -> "__" and " " -> "_" so it's a valid Zephyr module name. + assert "name: owner__My_Lib" in out + assert "cmake: zephyr" in out + + +def test_generate_cmakelists_txt_basic(tmp_path): + c = _make_component(tmp_path) + src = tmp_path / "src" + src.mkdir() + (src / "main.c").write_text("int main() {}") + c.data = {} + + out = generate_cmakelists_txt(c) + + assert "zephyr_library_named(mylib)" in out + assert "zephyr_library_sources(" in out + # Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir), + # backslash-escaped for CMake (matching the output on Windows). + assert str((src / "main.c").resolve()).replace("\\", "\\\\") in out + + +def test_generate_cmakelists_txt_flags_and_includes(tmp_path): + c = _make_component(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.c").write_text("") + (tmp_path / "include").mkdir() + c.data = {"build": {"flags": ["-Iinclude", "-DFOO", "-Wall", "-Llibdir", "-lm"]}} + + out = generate_cmakelists_txt(c) + + assert "zephyr_include_directories(" in out + assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out + assert "zephyr_library_compile_options(" in out + assert "-DFOO" in out + assert "-Wall" in out + assert "zephyr_link_libraries(" in out + assert "-Llibdir" in out + assert "-lm" in out + + +def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch): + # Two converted libraries: one top-level, one transitive dependency. The + # converter calls backend.emit for both; generate_zephyr_modules must return + # *all* module dirs (not just top-level) so every module is discoverable. + top = _make_component(tmp_path / "top", "top") + (top.path / "src").mkdir(parents=True) + (top.path / "src" / "t.c").write_text("") + dep = _make_component(tmp_path / "dep", "dep") + (dep.path / "src").mkdir(parents=True) + (dep.path / "src" / "d.c").write_text("") + + captured = {} + + def fake_convert(libraries, backend): + captured["platform"] = backend.platform + captured["framework"] = backend.framework + backend.emit(top) + backend.emit(dep) + return [top] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + dirs = generate_zephyr_modules([Library("top", "1.0", None)]) + + assert dirs == [top.path, dep.path] + # Platform check disabled for Zephyr; framework declared as zephyr. + assert captured["platform"] is None + assert captured["framework"] == "zephyr" + for comp in (top, dep): + assert (comp.path / "zephyr" / "module.yml").is_file() + assert (comp.path / "zephyr" / "CMakeLists.txt").is_file() + + +def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch): + # The same library referenced under inconsistent specs (e.g. bare vs + # owner-qualified, or git vs registry) resolves to two components with the + # same Zephyr module name, which would collide in zephyr_library_named(). + a = _make_component(tmp_path / "a", "esphome/noise-c") + a.path.mkdir(parents=True) + b = _make_component(tmp_path / "b", "esphome/noise-c") + b.path.mkdir(parents=True) + assert a.get_require_name() == b.get_require_name() + + def fake_convert(libraries, backend): + backend.emit(a) + backend.emit(b) + return [a] + + monkeypatch.setattr(zlib, "convert_libraries", fake_convert) + + with pytest.raises(EsphomeError, match="same Zephyr module"): + generate_zephyr_modules([Library("esphome/noise-c", "1.0", None)])