diff --git a/.clang-tidy b/.clang-tidy index ea7370a3b2..6dab84fbd9 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -116,7 +116,6 @@ Checks: >- -portability-template-virtual-member-function, -readability-ambiguous-smartptr-reset-call, -readability-avoid-nested-conditional-operator, - -readability-container-contains, -readability-container-data-pointer, -readability-convert-member-functions-to-static, -readability-else-after-return, diff --git a/.clang-tidy.hash b/.clang-tidy.hash deleted file mode 100644 index 77b4f5323f..0000000000 --- a/.clang-tidy.hash +++ /dev/null @@ -1 +0,0 @@ -593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..92706fed20 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }", + "statusMessage": "Setting up dev environment (script/setup)...", + "timeout": 900 + } + ] + } + ] + } +} diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md index 4ec2551804..2c529dcd0f 100644 --- a/.claude/skills/pr-workflow/SKILL.md +++ b/.claude/skills/pr-workflow/SKILL.md @@ -29,7 +29,7 @@ Required fields: - **What does this implement/fix?**: Brief description of changes - **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.) - **Related issue**: Use `fixes ` syntax if applicable -- **Pull request in esphome-docs**: Link if docs are needed +- **Pull request in esphome.io**: Link if docs are needed - **Test Environment**: Check platforms you tested on - **Example config.yaml**: Include working example YAML - **Checklist**: Verify code is tested and tests added @@ -54,9 +54,9 @@ Required fields: - fixes https://github.com/esphome/esphome/issues/XXX -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs#XXX +- esphome/esphome.io#XXX ## Test Environment @@ -83,7 +83,7 @@ component_name: - [x] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). ``` ## 5. Push and Create PR 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/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 19f52349a6..977fe9428d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,13 +2,13 @@ blank_issues_enabled: false contact_links: - name: Report an issue with the ESPHome documentation - url: https://github.com/esphome/esphome-docs/issues/new/choose + url: https://github.com/esphome/esphome.io/issues/new/choose about: Report an issue with the ESPHome documentation. - name: Report an issue with the ESPHome web server url: https://github.com/esphome/esphome-webserver/issues/new/choose about: Report an issue with the ESPHome web server. - name: Report an issue with the ESPHome Builder / Dashboard - url: https://github.com/esphome/dashboard/issues/new/choose + url: https://github.com/esphome/device-builder/issues/new/choose about: Report an issue with the ESPHome Builder / Dashboard. - name: Report an issue with the ESPHome API client url: https://github.com/esphome/aioesphomeapi/issues/new/choose diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 72013e411e..e708ae41b2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,6 +6,7 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) +- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change) - [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api) - [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations) @@ -16,9 +17,13 @@ - fixes -**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):** +**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):** -- esphome/esphome-docs# +- esphome/esphome.io# + +**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):** + +- esphome/developers.esphome.io# ## Test Environment @@ -43,4 +48,4 @@ - [ ] Tests have been added to verify that the new code works (under `tests/` folder). If user exposed functionality or configuration variables are added/changed: - - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs). + - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io). diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 52d72544d3..133d7ca8d8 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -15,11 +15,6 @@ inputs: description: "Version to build" required: true example: "2023.12.0" - base_os: - description: "Base OS to use" - required: false - default: "debian" - example: "debian" runs: using: "composite" steps: @@ -47,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -60,7 +55,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true @@ -73,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -86,7 +80,6 @@ runs: build-args: | BUILD_TYPE=${{ inputs.build_type }} BUILD_VERSION=${{ inputs.version }} - BUILD_OS=${{ inputs.base_os }} outputs: | type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml new file mode 100644 index 0000000000..b884e1e4c6 --- /dev/null +++ b/.github/actions/cache-esp-idf/action.yml @@ -0,0 +1,54 @@ +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 component test batches) shares + one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS + defaults to "all", and _get_configured_targets() in espidf/toolchain.py + skips per-variant narrowing whenever CI is set, so all toolchains are + present regardless of the chip a job builds. + Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the + Python venv already restored. +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: + - name: Resolve ESP-IDF version for cache key + # The native-IDF version is pinned in code, not in any file that feeds the + # other cache keys, so resolve it explicitly. Keying on it means the cache + # invalidates on a version bump (actions/cache never overwrites a key). + id: version + shell: bash + run: | + . venv/bin/activate + if [ "${{ inputs.framework }}" = "arduino" ]; then + version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])') + else + version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])') + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + # Mirror the adjacent PlatformIO cache: only dev-branch runs write the + # shared cache (so it lives in the default-branch scope readable by all + # 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' && 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' || inputs.restore-only == 'true' + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.esphome-idf + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} 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 21393f2aba..ce14b0152a 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,16 +17,31 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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 key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + 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. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows' shell: bash @@ -34,8 +49,8 @@ runs: python -m venv venv source venv/bin/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt + uv pip install -e . - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows' shell: bash @@ -43,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt + uv pip install -e . diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 528e69c478..e87939f824 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,7 @@ updates: directory: "/" schedule: interval: daily + open-pull-requests-limit: 10 ignore: # Hypotehsis is only used for testing and is updated quite often - dependency-name: hypothesis diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index e02b450bf0..5e8acb09c9 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -13,6 +13,7 @@ module.exports = { 'merging-to-release', 'merging-to-beta', 'chained-pr', + 'stacked-pr', 'core', 'small-pr', 'medium-pr', @@ -22,11 +23,13 @@ module.exports = { 'has-tests', 'needs-tests', 'needs-docs', + 'needs-developer-docs', 'needs-codeowners', 'too-big', 'labeller-recheck', 'bugfix', 'new-feature', + 'new-feature-developer', 'breaking-change', 'developer-breaking-change', 'undocumented-api-change', @@ -35,7 +38,22 @@ module.exports = { ], DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/, + /esphome\/esphome\.io#\d+/, + // Keep matching the old esphome-docs name during the transition period /https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/, /esphome\/esphome-docs#\d+/ + ], + + DEVELOPER_DOCS_PR_PATTERNS: [ + /https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/, + /esphome\/developers\.esphome\.io#\d+/ + ], + + // Files whose developer-facing changes are documented via Python docstrings + // only - developers.esphome.io has no reference page for them yet, so PRs + // touching nothing but these files (and tests/) skip needs-developer-docs. + DEV_DOCS_EXEMPT_FILES: [ + 'esphome/config_validation.py' ] }; diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 410c1a53c0..1d76c18be8 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -1,4 +1,4 @@ -const { DOCS_PR_PATTERNS } = require('./constants'); +const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants'); const { COMPONENT_REGEX, detectComponents, @@ -33,16 +33,54 @@ async function fetchPrFileContent(github, context, path) { } } +// Check whether a pull request is part of a GitHub stack. +// +// GitHub's stacked pull request feature adds a `stack` object to the pull +// request resource. It is present on every pull request in the stack - +// including the bottom one, whose base is already `dev` - and is absent +// entirely on standalone pull requests. +// +// The `pull_request_target` webhook payload is not guaranteed to carry this +// field, so fall back to asking the API when it is missing. Guessing wrong +// here is costly: a stacked pull request mistaken for a manually chained one +// gets a label that blocks merging. +async function isStackedPr(github, context) { + const pr = context.payload.pull_request; + if (pr.stack != null) { + return true; + } + + try { + const { owner, repo } = context.repo; + const { data } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pr.number, + }); + return data.stack != null; + } catch (error) { + // Treat an API failure as "not stacked" so a chained pull request still + // gets its blocking label rather than silently slipping through. + console.log('Failed to check stack membership:', error.message); + return false; + } +} + // Strategy: Merge branch detection -async function detectMergeBranch(context) { +async function detectMergeBranch(github, context) { const labels = new Set(); const baseRef = context.payload.pull_request.base.ref; + const defaultBranch = context.payload.repository.default_branch; if (baseRef === 'release') { labels.add('merging-to-release'); } else if (baseRef === 'beta') { labels.add('merging-to-beta'); - } else if (baseRef !== 'dev') { + } else if (await isStackedPr(github, context)) { + // GitHub manages the merge order for a stack, so these are not blocked. + labels.add('stacked-pr'); + } else if (baseRef !== defaultBranch) { + // A chain built by hand: it must not merge until its base branch does. labels.add('chained-pr'); } @@ -107,6 +145,8 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { /^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/, ]; + const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename)); + for (const file of addedFiles) { for (const re of platformPathPatterns) { const match = file.match(re); @@ -114,6 +154,12 @@ async function detectNewPlatforms(github, context, prFiles, apiData) { const platform = match[2]; if (!apiData.platformComponents.includes(platform)) break; + // Skip if this is a restructure between flat and subdirectory forms (either direction): + // /.py <-> //__init__.py + const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`; + const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`; + if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break; + labels.add('new-platform'); const content = await fetchPrFileContent(github, context, file); if (content === null) { @@ -139,19 +185,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); @@ -159,7 +195,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) { @@ -230,6 +283,7 @@ async function detectPRTemplateCheckboxes(context) { const checkboxPatterns = [ { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, + { pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, { pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' }, @@ -340,12 +394,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) const labels = new Set(); // Check for missing tests - if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) { + if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) { labels.add('needs-tests'); } // Check for missing docs. - // `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform` + // `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is + // deliberately excluded here: its docs live on developers.esphome.io and are + // checked separately below. `new-component` / `new-platform` // only count when at least one newly added file defines a top-level CONFIG_SCHEMA, // i.e. the new component/platform is actually loadable from YAML. const docsEligible = @@ -361,6 +417,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) } } + // Check for missing developer docs. `new-feature-developer` requires a + // developers.esphome.io PR link, unless every changed file outside tests/ is + // in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only). + if (allLabels.has('new-feature-developer')) { + const prBody = context.payload.pull_request.body || ''; + const nonTestFiles = prFiles + .map(file => file.filename) + .filter(file => !file.startsWith('tests/')); + const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file)); + const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody)); + + if (!onlyExemptFiles && !hasDevDocsLink) { + labels.add('needs-developer-docs'); + } + } + // Check for missing CODEOWNERS if (allLabels.has('new-component')) { const codeownersModified = prFiles.some(file => diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 9769cd8060..8b0c821503 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => { // Early exit for release and beta branches only if (baseRef === 'release' || baseRef === 'beta') { - const branchLabels = await detectMergeBranch(context); + const branchLabels = await detectMergeBranch(github, context); const finalLabels = Array.from(branchLabels); console.log('Computed labels (merge branch only):', finalLabels.join(', ')); @@ -118,12 +118,12 @@ module.exports = async ({ github, context }) => { deprecatedResult, maintainerAccess ] = await Promise.all([ - detectMergeBranch(context), + detectMergeBranch(github, context), detectComponentPlatforms(changedFiles, apiData), 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/package.json b/.github/scripts/auto-label-pr/package.json new file mode 100644 index 0000000000..401b376db6 --- /dev/null +++ b/.github/scripts/auto-label-pr/package.json @@ -0,0 +1,7 @@ +{ + "name": "auto-label-pr", + "private": true, + "scripts": { + "test": "node --test tests/*.test.js" + } +} diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js new file mode 100644 index 0000000000..be239e2f1b --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -0,0 +1,466 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + detectMergeBranch, + detectNewPlatforms, + detectNewComponents, + detectPRSize, + detectPRTemplateCheckboxes, + detectRequirements, +} = require('../detectors'); +const { MANAGED_LABELS } = require('../constants'); + +// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents +// to check for CONFIG_SCHEMA in newly added files. +function makeGithub(content = '') { + return { + rest: { + repos: { + getContent: async () => ({ + data: { content: Buffer.from(content).toString('base64') } + }) + } + } + }; +} + +const CONTEXT = { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } } +}; + +const API_DATA = { + targetPlatforms: ['esp32', 'esp8266', 'rp2040'], + platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve'] +}; + +const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; +const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; + +// --------------------------------------------------------------------------- +// detectMergeBranch +// --------------------------------------------------------------------------- + +// Builds a fresh context for detectMergeBranch tests instead of mutating the +// shared CONTEXT fixture above (which other describe blocks rely on). +function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) { + const pull_request = { number: 1, base: { ref: baseRef } }; + if (stack !== undefined) { + pull_request.stack = stack; + } + return { + repo: { owner: 'esphome', repo: 'esphome' }, + payload: { pull_request, repository: { default_branch: defaultBranch } } + }; +} + +// A GitHub API mock exposing only rest.pulls.get, with a call counter so +// tests can assert whether the API fallback was actually invoked. +function makeStackGithub({ stack = null, error = null } = {}) { + const state = { calls: 0 }; + const github = { + rest: { + pulls: { + get: async () => { + state.calls++; + if (error) throw error; + return { data: { stack } }; + } + } + } + }; + return { github, state }; +} + +const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 }; + +describe('detectMergeBranch', () => { + it('base ref release adds merging-to-release only and never checks the stack', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('release', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']); + assert.equal(state.calls, 0); + }); + + it('base ref beta adds merging-to-beta only and never checks the stack', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('beta', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']); + assert.equal(state.calls, 0); + }); + + it('stack present on the webhook payload adds stacked-pr without calling the API', async () => { + const { github, state } = makeStackGithub(); + const context = makeMergeContext('feature-branch', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 0); + }); + + it('stack absent from payload falls back to the API and adds stacked-pr', async () => { + const { github, state } = makeStackGithub({ stack: STACK_INFO }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 1); + }); + + it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => { + const { github, state } = makeStackGithub(); + const context = makeMergeContext('dev', { stack: STACK_INFO }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']); + assert.equal(state.calls, 0); + }); + + it('not stacked, base ref not dev adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + + it('not stacked, base ref dev adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => { + const { github, state } = makeStackGithub({ error: new Error('API unavailable') }); + const context = makeMergeContext('feature-branch'); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + assert.equal(state.calls, 1); + }); + + it('base ref matches default branch adds no labels', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('other', { defaultBranch: 'other' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), []); + }); + + it('base ref dev when the default branch is main adds chained-pr', async () => { + const { github } = makeStackGithub({ stack: null }); + const context = makeMergeContext('dev', { defaultBranch: 'main' }); + const labels = await detectMergeBranch(github, context); + assert.deepEqual(Array.from(labels).sort(), ['chained-pr']); + }); + +}); + +// --------------------------------------------------------------------------- +// detectNewPlatforms +// --------------------------------------------------------------------------- + +describe('detectNewPlatforms', () => { + describe('restructure detection (no false positives)', () => { + it('flat .py -> subdir __init__.py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + + it('subdir __init__.py -> flat .py is not a new platform', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' }, + { filename: 'esphome/components/endstop/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); + + describe('genuine new platforms', () => { + it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/cover.py', status: 'added' }, + ]; + const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA); + assert.ok(result.labels.has('new-platform')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('non-platform file addition produces no labels', async () => { + const prFiles = [ + { filename: 'esphome/components/my_sensor/sensor.py', status: 'added' }, + ]; + // Override platformComponents so 'sensor' is not a recognized platform -> no label expected. + const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] }; + const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData); + assert.equal(result.labels.size, 0); + assert.equal(result.hasYamlLoadable, false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// detectNewComponents +// --------------------------------------------------------------------------- + +describe('detectNewComponents', () => { + it('new top-level __init__.py sets new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/actuator/__init__.py', status: 'added', }, + ]; + const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, false); + }); + + it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => { + const prFiles = [ + { filename: 'esphome/components/my_component/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.equal(result.hasYamlLoadable, true); + }); + + it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => { + const prFiles = [ + { filename: 'esphome/components/my_platform/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles); + assert.ok(result.labels.has('new-component')); + assert.ok(result.labels.has('new-target-platform')); + }); + + it('modified __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/existing/__init__.py', status: 'modified' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); + + it('nested __init__.py does not set new-component', async () => { + const prFiles = [ + { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' }, + ]; + const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles); + assert.equal(result.labels.size, 0); + }); +}); + +// --------------------------------------------------------------------------- +// detectPRTemplateCheckboxes +// --------------------------------------------------------------------------- + +const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)'; +const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)'; +const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)'; + +function makeBodyContext(body) { + return { payload: { pull_request: { body } } }; +} + +describe('detectPRTemplateCheckboxes', () => { + it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE)); + assert.ok(labels.has('new-feature-developer')); + assert.ok(!labels.has('new-feature')); + }); + + it('unticked developer-facing feature checkbox adds no label', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED)); + assert.ok(!labels.has('new-feature-developer')); + }); + + it('ticked new feature checkbox does not add new-feature-developer', async () => { + const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE)); + assert.ok(labels.has('new-feature')); + assert.ok(!labels.has('new-feature-developer')); + }); +}); + +// --------------------------------------------------------------------------- +// detectRequirements +// --------------------------------------------------------------------------- + +describe('detectRequirements', () => { + // PR body without any docs-PR link. + const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.'); + const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234'); + const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234'); + const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234'); + + // File sets: a normal source change vs. one confined to the exempt core validators. + const SOURCE_FILES = [ + { filename: 'esphome/components/foo/foo.py' }, + { filename: 'tests/components/foo/common.yaml' }, + ]; + const VALIDATOR_FILES = [ + { filename: 'esphome/config_validation.py' }, + { filename: 'tests/unit_tests/test_config_validation.py' }, + ]; + + it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-tests')); + assert.ok(!labels.has('needs-docs')); + }); + + it('new-feature-developer with has-tests does not add needs-tests', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-tests')); + }); + + it('new-feature without a docs link still adds needs-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-docs')); + }); + + it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); + + it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); + + it('a developer docs link does not satisfy needs-docs for new-feature', async () => { + const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-docs')); + }); + + it('changes confined to core validator files are exempt from needs-developer-docs', async () => { + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false); + assert.ok(!labels.has('needs-developer-docs')); + }); + + it('validator changes mixed with other source files are not exempt', async () => { + const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }]; + const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false); + assert.ok(labels.has('needs-developer-docs')); + }); +}); + +// --------------------------------------------------------------------------- +// MANAGED_LABELS +// --------------------------------------------------------------------------- + +describe('MANAGED_LABELS', () => { + it('includes new-feature-developer so the workflow syncs it', () => { + assert.ok(MANAGED_LABELS.includes('new-feature-developer')); + }); + + it('includes needs-developer-docs so the workflow syncs it', () => { + assert.ok(MANAGED_LABELS.includes('needs-developer-docs')); + }); +}); + +// --------------------------------------------------------------------------- +// 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 2d000658a2..b49c66b976 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,18 +24,18 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} # Scope the minted App token to the minimum needed by auto-label-pr/*.js. permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment - permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview + permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview - name: Auto Label PR uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 2f7fd271ba..63219a1dbc 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,20 +21,55 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + 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. + version: "0.11.15" - name: Install apt dependencies + # PR-only workflow, so nothing on dev could seed a shared apt cache + # entry; the cached apt action would save one copy per PR. Plain apt + # with every call bounded: the apt.conf.d timeouts make a dead + # mirror fail over in seconds, and timeout runs under sudo so it can + # kill apt-get itself. Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes slow. + timeout-minutes: 15 run: | - sudo apt update - sudo apt-cache show protobuf-compiler - sudo apt install -y protobuf-compiler + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y protobuf-compiler; then + protoc --version + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y protobuf-compiler protoc --version - name: Install python dependencies - run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt + run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt - name: Generate files run: script/api_protobuf/api_protobuf.py - name: Check for changes diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml deleted file mode 100644 index d9148fb06d..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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - 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 3fd17888c7..829bdd5f98 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -1,28 +1,41 @@ --- name: CI for docker images -# Only run when docker paths change +# Only run on PRs that touch the docker image, its build inputs, or any code +# whose toolchain the compile smoke test exercises (core + target platforms). on: - push: - branches: [dev, beta, release] - paths: - - "docker/**" - - ".github/workflows/ci-docker.yml" - - "requirements*.txt" - - "platformio.ini" - - "script/platformio_install_deps.py" - pull_request: paths: + # Docker image and its build inputs. - "docker/**" - ".github/workflows/ci-docker.yml" - "requirements*.txt" + - "pyproject.toml" - "platformio.ini" + - "esphome/idf_component.yml" - "script/platformio_install_deps.py" + # Core, build pipeline, toolchain, and target-platform changes can change + # how a toolchain is set up or built, so re-run the per-toolchain compile + # smoke test when they change. + - "esphome/core/**" + - "esphome/writer.py" + - "esphome/build_gen/**" + - "esphome/espidf/**" + - "esphome/platformio/**" + - "esphome/components/bk72xx/**" + - "esphome/components/esp32/**" + - "esphome/components/esp8266/**" + - "esphome/components/host/**" + - "esphome/components/libretiny/**" + - "esphome/components/ln882x/**" + - "esphome/components/nrf52/**" + - "esphome/components/rp2040/**" + - "esphome/components/rtl87xx/**" + - "esphome/components/zephyr/**" permissions: - contents: read # actions/checkout only; the build does not push images + contents: read # actions/checkout only concurrency: # yamllint disable-line rule:line-length @@ -33,6 +46,9 @@ jobs: check-docker: name: Build docker containers runs-on: ${{ matrix.os }} + permissions: + contents: read # actions/checkout to load Dockerfile and build context + packages: write # push branch-tagged images to ghcr.io for local testing strategy: fail-fast: false matrix: @@ -41,23 +57,167 @@ jobs: - "ha-addon" - "docker" # - "lint" + outputs: + tag: ${{ steps.tag.outputs.tag }} + push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - name: Set TAG + - name: Determine tag and whether to push + id: tag + env: + HEAD_REF: ${{ github.head_ref || github.ref_name }} run: | - echo "TAG=check" >> $GITHUB_ENV + # Sanitize the branch name into a valid docker tag: replace invalid + # characters, ensure the first character is valid (tags must start + # with [A-Za-z0-9_]), and cap the length at 128 characters. + branch="$HEAD_REF" + tag="${branch//[^a-zA-Z0-9_.-]/-}" + case "$tag" in + [a-zA-Z0-9_]*) ;; + *) tag="pr-${tag}" ;; + esac + tag="${tag:0:128}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + # Only push branch images for same-repo pull requests. Push events + # only fire for dev/beta/release, whose images are owned by the + # release pipeline -- never overwrite those from here. + if [ "${{ github.event_name }}" = "pull_request" ] \ + && [ "${{ github.repository }}" = "esphome/esphome" ] \ + && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then + echo "push=true" >> "$GITHUB_OUTPUT" + else + echo "push=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to the GitHub container registry + if: steps.tag.outputs.push == 'true' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run build run: | docker/build.py \ - --tag "${TAG}" \ + --tag "${{ steps.tag.outputs.tag }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --build-type "${{ matrix.build_type }}" \ - build + --registry ghcr \ + build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }} + + # The amd64 "docker" image is also loaded locally (above) and handed to + # compile-test as an artifact, so the smoke test reuses this build instead + # of building the image a second time. Using an artifact (rather than the + # pushed image) keeps it working for fork PRs, which never push to ghcr.io. + - name: Export image for compile-test + if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' + # zstd over gzip: docker save is on the critical path for every + # compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s). + # docker load auto-detects the format; its time is layer extraction, + # not decompression, so it is unchanged. shell: bash adds pipefail so + # a failed docker save cannot upload a truncated artifact. + shell: bash + run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst + + - 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 compressed, 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.zst + retention-days: 1 + archive: false + + manifest: + name: Push ${{ matrix.build_type }} manifest to ghcr.io + needs: [check-docker] + if: needs.check-docker.outputs.push == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to run docker/build.py + packages: write # buildx imagetools writes the multi-arch tag to ghcr.io + strategy: + fail-fast: false + matrix: + build_type: + - "ha-addon" + - "docker" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Log in to the GitHub container registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + docker/build.py \ + --tag "${{ needs.check-docker.outputs.tag }}" \ + --build-type "${{ matrix.build_type }}" \ + --registry ghcr \ + manifest + + # Smoke-test the built image by compiling one minimal config per target + # platform / toolchain. This catches missing system dependencies in the image + # that only surface when a given toolchain is downloaded and run. The image is + # the amd64 "docker" build produced by check-docker (shared as an artifact). + compile-test: + name: Compile ${{ matrix.id }} + needs: check-docker + runs-on: ubuntu-24.04 + permissions: + contents: read # actions/checkout to load the test configs + strategy: + fail-fast: false + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download image artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: compile-test-image.tar.zst + - name: Load image + run: docker load --input compile-test-image.tar.zst + - 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 new file mode 100644 index 0000000000..ea039de9b9 --- /dev/null +++ b/.github/workflows/ci-github-scripts.yml @@ -0,0 +1,27 @@ +name: CI - GitHub Scripts + +on: + push: + branches: [dev, beta, release] + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + pull_request: + paths: + - ".github/scripts/**" + - ".github/workflows/ci-github-scripts.yml" + +permissions: + contents: read + +jobs: + test-auto-label-pr: + name: Test auto-label-pr scripts + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run tests + working-directory: .github/scripts/auto-label-pr + run: npm test diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 025b960985..0dc653bd39 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 ad39b3f346..cbf6e070b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,22 +6,14 @@ on: branches: [dev, beta, release] pull_request: - paths: - - "**" - - "!.github/workflows/*.yml" - - "!.github/actions/build-image/*" - - ".github/workflows/ci.yml" - - "!.yamllint" - - "!.github/dependabot.yml" - - "!docker/**" merge_group: 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 @@ -36,30 +28,185 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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 key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }} + - name: Set up uv + # Only needed on cache miss to populate the venv. ``uv pip install`` + # 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + 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. + version: "0.11.15" - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' run: | python -m venv venv . venv/bin/activate python --version - pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit - pip install -e . + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt + uv pip install -e . + + seed-apt-cache: + name: Seed apt package cache + runs-on: ubuntu-24.04 + # PR-branch cache saves are invisible to other PRs, so dev/beta/release + # pushes seed the one shared entry PR jobs restore. The key is derived + # only from the package list and version; keep both identical in every + # step that restores it. In ci-status needs so a broken seed fails dev. + if: github.event_name == 'push' + timeout-minutes: 10 + steps: + - name: Install apt packages (cached) + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev ccache + version: 1.1 + + determine-jobs: + name: Determine which jobs to run + runs-on: ubuntu-24.04 + needs: + - common + outputs: + core-ci: ${{ steps.determine.outputs.core-ci }} + integration-tests: ${{ steps.determine.outputs.integration-tests }} + integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} + clang-tidy: ${{ steps.determine.outputs.clang-tidy }} + clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} + clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }} + python-linters: ${{ steps.determine.outputs.python-linters }} + import-time: ${{ steps.determine.outputs.import-time }} + device-builder: ${{ steps.determine.outputs.device-builder }} + 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 }} + component-test-count: ${{ steps.determine.outputs.component-test-count }} + changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }} + memory_impact: ${{ steps.determine.outputs.memory-impact }} + cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }} + cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }} + component-test-batches: ${{ steps.determine.outputs.component-test-batches }} + validate-only-components: ${{ steps.determine.outputs.validate-only-components }} + benchmarks: ${{ steps.determine.outputs.benchmarks }} + # "true" when this run is a pull request into one of the release + # branches. Those pull requests are batches of changes already tested on + # their original dev pull requests, so several jobs below trade coverage + # for turnaround time on them. Matched exactly, not by prefix, so an + # ordinary branch named e.g. "release-notes" is not caught by it. + release-pr: ${{ github.base_ref == 'beta' || github.base_ref == 'release' }} + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Fetch enough history to find the merge base + fetch-depth: 2 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Restore components graph cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .temp/components_graph.json + key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + - name: Determine which tests to run + id: determine + env: + GH_TOKEN: ${{ github.token }} + run: | + . venv/bin/activate + EXTRA_ARGS="" + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then + EXTRA_ARGS="--force-all" + echo "::notice::ci-run-all label detected -- forcing every CI job to run" + fi + output=$(python script/determine-jobs.py $EXTRA_ARGS) + echo "Test determination output:" + echo "$output" | jq + + # Extract individual fields + echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT + echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT + echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT + echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT + echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT + echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT + 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 "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 + echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT + echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT + echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT + echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT + echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT + echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT + echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT + echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT + - name: Save components graph cache + if: github.ref == 'refs/heads/dev' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .temp/components_graph.json + key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + + ci-custom: + name: Run script/ci-custom + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" + - name: Run script/ci-custom + run: | + . venv/bin/activate + script/ci-custom.py + script/build_codeowners.py --check + script/build_alias_registry.py --check + script/build_language_schema.py --check + script/generate-esp32-boards.py --check + script/generate-rp2-boards.py --check + script/ci_check_duplicate_test_ids.py + script/ci_check_test_fixture_list_form.py pylint: name: Check pylint @@ -70,7 +217,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -84,29 +231,191 @@ jobs: run: script/ci-suggest-changes if: always() - ci-custom: - name: Run script/ci-custom + lint-format: + name: Check lint and formatting + runs-on: ubuntu-latest + needs: + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.release-pr == 'false' && needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Run prek + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 + with: + # Keep in sync with requirements_test.txt. + prek-version: "0.4.11" + # This job only runs on pull requests, so nothing ever populates + # the cache on dev. Every run would miss and then write a per-pull + # request copy, which is what the old seed-cache job existed to + # avoid. Building the hooks from scratch takes seconds, so skip it. + cache: false + env: + PREK_SKIP: pylint,ci-custom + # Pushes any fixes the hooks made back to the pull request. This step + # must keep its default name: the GitHub App that performs the push + # locates the workflow run by that name. + - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 + if: always() + with: + msg: apply automatic formatting fixes + + pytest: + name: Run pytest + strategy: + fail-fast: false + matrix: + python-version: + - "3.12" + - "3.13" + - "3.14" + os: + - ubuntu-latest + - macOS-latest + - windows-latest + exclude: + # Minimize CI resource usage + # by only running the Python version + # version used for docker images on Windows and macOS + - python-version: "3.13" + os: windows-latest + - python-version: "3.13" + os: macOS-latest + runs-on: ${{ matrix.os }} + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.core-ci == 'true' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + id: restore-python + uses: ./.github/actions/restore-python + with: + python-version: ${{ matrix.python-version }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/pytest.json" + - name: Run pytest + if: matrix.os == 'windows-latest' + run: | + . ./venv/Scripts/activate.ps1 + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ + - name: Run pytest + if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' + run: | + . venv/bin/activate + pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ + - name: Upload coverage to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + - name: Save Python virtual environment cache + if: github.ref == 'refs/heads/dev' + 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 }} + + codecov-empty-upload: + name: Report no coverage to Codecov + runs-on: ubuntu-24.04 + needs: + - determine-jobs + # ``pytest`` is the only job that uploads coverage, and it is skipped when + # every changed file is CI-irrelevant (see ``should_run_core_ci`` in + # ``script/determine-jobs.py``). With no upload Codecov never reports a + # result, so the required ``codecov/patch`` status stays pending forever and + # the pull request can never be merged. Tell Codecov up front that this + # commit has nothing to cover so it publishes a passing status instead. + # + # ``force`` skips Codecov's own check that every changed file is ignorable; + # ``determine-jobs`` has already decided none of these files can affect + # coverage, and Codecov would otherwise fail the status for paths it does + # not recognise as non-testable (``docker/**``, ``.yamllint``). + if: needs.determine-jobs.outputs.core-ci == 'false' + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Report empty upload to Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + run_command: empty-upload + force: true + fail_ci_if_error: true + + integration-tests: + name: Run integration tests (${{ matrix.bucket.name }}) + # Must match seed-apt-cache's image: the apt cache key has no OS in it. runs-on: ubuntu-24.04 needs: - common + - determine-jobs + if: needs.determine-jobs.outputs.integration-tests == 'true' + strategy: + fail-fast: false + matrix: + bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Restore Python - uses: ./.github/actions/restore-python + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install apt packages (cached) + # ccache speeds up the host compiles. A cache hit never touches apt + # (mirror outages cannot hang the job); the timeout bounds the cold + # path. Packages and version must match seed-apt-cache exactly; + # libsdl2-dev is unused here and carried only for cache-key parity. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} + packages: libsdl2-dev ccache + version: 1.1 + - name: Set up Python 3.13 + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Restore Python virtual environment + id: cache-venv + 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + 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. + version: "0.11.15" + - name: Create Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv venv + . venv/bin/activate + python --version + uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -e . - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" - - name: Run script/ci-custom + run: echo "::add-matcher::.github/workflows/matchers/pytest.json" + - name: Run integration tests + env: + # JSON array of test paths; parsed into a bash array below to avoid + # shell word-splitting / glob hazards. + BUCKET_TESTS: ${{ toJson(matrix.bucket.tests) }} run: | . venv/bin/activate - script/ci-custom.py - script/build_codeowners.py --check - script/build_language_schema.py --check - script/generate-esp32-boards.py --check - script/generate-rp2040-boards.py --check + mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') + echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" + pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" + - name: Print ccache statistics + # esphome stores the PlatformIO ccache under the machine-global cache + # dir (see _ccache_env() in esphome/platformio/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s import-time: name: Check import esphome.__main__ time @@ -117,7 +426,7 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -136,230 +445,104 @@ jobs: if-no-files-found: ignore retention-days: 14 - device-builder: - name: Test downstream esphome/device-builder + benchmarks: + name: Run CodSpeed benchmarks runs-on: ubuntu-24.04 + timeout-minutes: 30 needs: - common - determine-jobs - if: needs.determine-jobs.outputs.device-builder == 'true' - steps: - - name: Check out esphome (this PR) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - path: esphome - - name: Check out esphome/device-builder - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: esphome/device-builder - ref: main - path: device-builder - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.13" - - name: Set up uv - # Mirrors the install shape device-builder's own CI uses - # (esphome/device-builder#192): uv replaces pip for the - # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - enable-cache: true - - name: Install device-builder + esphome from PR - # Install device-builder with its esphome + test extras - # first so its pinned versions of pytest/etc. land, then - # overlay the PR's esphome so the downstream tests run - # against this PR's Python code. ``--system`` installs into - # the runner's Python instead of a venv. - run: | - uv pip install --system -e './device-builder[esphome,test]' - uv pip install --system -e ./esphome - - name: Run device-builder pytest - # ``-n auto`` runs under pytest-xdist (matches device-builder's - # own CI). No ``--cov`` here -- this is purely a downstream - # smoke check against this PR's esphome code. - working-directory: device-builder - run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks - - pytest: - name: Run pytest - strategy: - fail-fast: false - matrix: - python-version: - - "3.11" - - "3.13" - - "3.14" - os: - - ubuntu-latest - - macOS-latest - - windows-latest - exclude: - # Minimize CI resource usage - # by only running the Python version - # version used for docker images on Windows and macOS - - python-version: "3.13" - os: windows-latest - - python-version: "3.13" - os: macOS-latest - runs-on: ${{ matrix.os }} - needs: - - common + if: >- + github.repository == 'esphome/esphome' && ( + (github.event_name == 'push' && github.ref_name == 'dev') || + ( + github.event_name == 'pull_request' && + needs.determine-jobs.outputs.release-pr == 'false' && + needs.determine-jobs.outputs.benchmarks == 'true' + ) + ) + # CodSpeed benchmarks require a CodSpeed account linked to the repository to run + # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself. + # + # Pull requests into beta and release are skipped as well. CodSpeed compares a + # pull request against the newest commit of its base branch that has a benchmark + # run of its own, and only dev is benchmarked. A release pull request therefore + # falls back to dev's latest run, so every speed-up merged into dev since the + # release branched is reported as a regression in the release. The changes there + # have already been benchmarked on their original dev pull requests. steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Restore Python - id: restore-python - uses: ./.github/actions/restore-python - with: - python-version: ${{ matrix.python-version }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - - name: Run pytest - if: matrix.os == 'windows-latest' - run: | - . ./venv/Scripts/activate.ps1 - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - - name: Run pytest - if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest' - run: | - . venv/bin/activate - pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - name: Save Python virtual environment cache - if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - determine-jobs: - name: Determine which jobs to run - runs-on: ubuntu-24.04 - needs: - - common - outputs: - integration-tests: ${{ steps.determine.outputs.integration-tests }} - integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }} - clang-tidy: ${{ steps.determine.outputs.clang-tidy }} - clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }} - python-linters: ${{ steps.determine.outputs.python-linters }} - import-time: ${{ steps.determine.outputs.import-time }} - device-builder: ${{ steps.determine.outputs.device-builder }} - 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 }} - component-test-count: ${{ steps.determine.outputs.component-test-count }} - changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }} - memory_impact: ${{ steps.determine.outputs.memory-impact }} - cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }} - cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }} - component-test-batches: ${{ steps.determine.outputs.component-test-batches }} - validate-only-components: ${{ steps.determine.outputs.validate-only-components }} - benchmarks: ${{ steps.determine.outputs.benchmarks }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # Fetch enough history to find the merge base - fetch-depth: 2 - name: Restore Python uses: ./.github/actions/restore-python with: 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 - with: - path: .temp/components_graph.json - key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} - - name: Determine which tests to run - id: determine - env: - GH_TOKEN: ${{ github.token }} - run: | - . venv/bin/activate - output=$(python script/determine-jobs.py) - echo "Test determination output:" - echo "$output" | jq - # Extract individual fields - echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT - echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT - echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT - echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT - 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 "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 - echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT - echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT - echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT - echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT - echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT - echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT - echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT - 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 - with: - path: .temp/components_graph.json - key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + - name: Build benchmarks + id: build + run: | + # pipefail: without it a failed build is masked by the grep/cut + # pipeline below, leaving BINARY empty and silently dropping every + # C++ benchmark from the run while the job still reports success. + set -o pipefail + . venv/bin/activate + BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) + export BENCHMARK_LIB_CONFIG + # --build-only prints BUILD_BINARY= to stdout; the grep is + # non-fatal so a missing marker reaches the check below instead of + # tripping errexit at this assignment + BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-) + if [ -z "$BINARY" ]; then + echo "::error::Benchmark build did not report a binary path" + exit 1 + fi + echo "binary=$BINARY" >> $GITHUB_OUTPUT - integration-tests: - name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.integration-tests == 'true' - strategy: - fail-fast: false - matrix: - bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up Python 3.13 - id: python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.13" - - name: Restore Python virtual environment - id: cache-venv - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: venv - key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} - - name: Create Python virtual environment - if: steps.cache-venv.outputs.cache-hit != 'true' + - name: Bound apt fetches and pre-install libc6-dbg + # The CodSpeed runner installs valgrind + libc6-dbg via its own + # unbounded apt-get update; per-invocation apt options cannot reach + # it. The apt.conf.d timeouts below bound every later apt call in + # this job, the runner's included. Pre-installing libc6-dbg lets the + # runner skip apt once its valgrind cache is restored (it checks + # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores + # would not count). Install without update first: image lists are + # fresh, and the index refresh is what a congested mirror makes + # slow. Best effort; the job timeout is the last backstop. + timeout-minutes: 15 + continue-on-error: true run: | - python -m venv venv - . venv/bin/activate - python --version - pip install -r requirements.txt -r requirements_test.txt - pip install -e . - - name: Register matcher - run: echo "::add-matcher::.github/workflows/matchers/pytest.json" - - name: Run integration tests - env: - # JSON array of test paths; parsed into a bash array below to avoid - # shell word-splitting / glob hazards. - BUCKET_TESTS: ${{ toJson(matrix.bucket.tests) }} - run: | - . venv/bin/activate - mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') - echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native -n auto "${test_files[@]}" + sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF' + Acquire::Retries "1"; + Acquire::http::Timeout "15"; + Acquire::https::Timeout "15"; + EOF + if dpkg -s libc6-dbg >/dev/null 2>&1; then + echo "libc6-dbg already installed" + exit 0 + fi + # Common path: the image's package lists are fresh enough. + if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \ + apt-get install -y libc6-dbg; then + exit 0 + fi + # Rescue path: refresh the lists once with a generous bound; the + # apt config already fails a stalled mirror over quickly. + sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \ + dpkg --configure -a || true + sudo timeout -k 15 300 apt-get update + sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \ + apt-get install -y libc6-dbg + + - name: Run CodSpeed benchmarks + uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 + with: + run: | + . venv/bin/activate + ${{ steps.build.outputs.binary }} + pytest tests/benchmarks/python/ --codspeed --no-cov + mode: simulation cpp-unit-tests: name: Run C++ unit tests @@ -370,7 +553,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -388,40 +571,6 @@ jobs: script/cpp_unit_test.py $ARGS fi - benchmarks: - name: Run CodSpeed benchmarks - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: >- - (github.event_name == 'push' && github.ref_name == 'dev') || - (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') - steps: - - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Restore Python - uses: ./.github/actions/restore-python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - - name: Build benchmarks - id: build - run: | - . venv/bin/activate - export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py) - # --build-only prints BUILD_BINARY= to stdout - BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-) - echo "binary=$BINARY" >> $GITHUB_OUTPUT - - - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1 - with: - run: ${{ steps.build.outputs.binary }} - mode: simulation - clang-tidy-single: name: ${{ matrix.name }} runs-on: ubuntu-24.04 @@ -431,9 +580,12 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy == 'true' env: 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 matrix: include: - id: clang-tidy @@ -441,61 +593,81 @@ jobs: options: --environment esp8266-arduino-tidy --grep USE_ESP8266 pio_cache_key: tidyesp8266 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF - options: --environment esp32-idf-tidy --grep USE_ESP_IDF - pio_cache_key: tidyesp32-idf + name: Run script/clang-tidy for ESP32 Arduino + options: --environment esp32-arduino-tidy --grep USE_ARDUINO + cache_idf: true - 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 + - id: clang-tidy + name: Run script/clang-tidy for RP2 + options: --environment rp2-tidy --grep USE_RP2 + pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for LibreTiny + environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + pio_cache_key: tidylibretiny steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 - name: Restore Python + id: restore-python uses: ./.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} + # Key on the exact Python version as well: LibreTiny creates a venv under + # ~/.platformio/penv whose interpreter is a symlink into the runner's + # hosted toolcache, so a cache saved on an older runner image breaks once + # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.platformio - key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} + key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} + + - name: Cache ESP-IDF 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" echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" - - name: Run 'pio run --list-targets -e esp32-idf-tidy' - if: matrix.name == 'Run script/clang-tidy for ESP32 IDF' - run: | - . venv/bin/activate - mkdir -p .temp - pio run --list-targets -e esp32-idf-tidy - - name: Check if full clang-tidy scan needed id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # 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=hash_changed" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -505,11 +677,22 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" - script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" + changed="" else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} + changed="--changed" + fi + if [ -n "${{ matrix.environments }}" ]; then + rc=0 + for env in ${{ matrix.environments }}; do + echo "::group::clang-tidy $env" + script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1 + echo "::endgroup::" + done + exit $rc + else + script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} fi env: # Also cache libdeps, store them in a ~/.platformio subfolder @@ -521,7 +704,7 @@ jobs: if: always() clang-tidy-nosplit: - name: Run script/clang-tidy for ESP32 Arduino + name: Run script/clang-tidy for ESP32 IDF runs-on: ubuntu-24.04 needs: - common @@ -529,9 +712,11 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -542,19 +727,8 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} + - name: Cache ESP-IDF install + uses: ./.github/actions/cache-esp-idf - name: Register problem matchers run: | @@ -565,9 +739,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # 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=hash_changed" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -577,11 +754,11 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" - script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" + script/clang-tidy --all-headers --fix --environment esp32-idf-tidy else echo "Running clang-tidy on changed files only" - script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy + script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy fi env: # Also cache libdeps, store them in a ~/.platformio subfolder @@ -600,27 +777,25 @@ jobs: if: needs.determine-jobs.outputs.clang-tidy-mode == 'split' env: GH_TOKEN: ${{ github.token }} + # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 2 matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 1/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 2/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 3/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 3 - - id: clang-tidy - name: Run script/clang-tidy for ESP32 Arduino 4/4 - options: --environment esp32-arduino-tidy --split-num 4 --split-at 4 + name: Run script/clang-tidy for ESP32 IDF 3/3 + options: --environment esp32-idf-tidy --split-num 3 --split-at 3 steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -631,19 +806,8 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: github.ref == 'refs/heads/dev' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - - - name: Cache platformio - if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.platformio - key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} + - name: Cache ESP-IDF install + uses: ./.github/actions/cache-esp-idf - name: Register problem matchers run: | @@ -654,9 +818,12 @@ jobs: id: check_full_scan run: | . venv/bin/activate - if python script/clang_tidy_hash.py --check; then + # 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=hash_changed" >> $GITHUB_OUTPUT + echo "reason=determine_jobs" >> $GITHUB_OUTPUT else echo "full_scan=false" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT @@ -666,7 +833,7 @@ jobs: run: | . venv/bin/activate if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then - echo "Running FULL clang-tidy scan (hash changed)" + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" script/clang-tidy --all-headers --fix ${{ matrix.options }} else echo "Running clang-tidy on changed files only" @@ -680,18 +847,107 @@ jobs: run: script/ci-suggest-changes if: always() + clang-tidy-esp32-variants: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.clang-tidy == 'true' + env: + GH_TOKEN: ${{ github.token }} + # The variant tidy envs install ESP-IDF natively; share the native IDF cache. + ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf + strategy: + fail-fast: false + matrix: + include: + - id: clang-tidy + name: Run script/clang-tidy for ESP32 S3 + # yamllint disable-line rule:line-length + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC + - id: clang-tidy + name: Run script/clang-tidy for ESP32 P4 + # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, + # so their code paths differ -- lint them under the P4 build too. + # yamllint disable-line rule:line-length + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + - id: clang-tidy + name: Run script/clang-tidy for ESP32 C6 + # yamllint disable-line rule:line-length + options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE + + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Need history for HEAD~1 to work for checking changed files + fetch-depth: 2 + + - 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 + uses: ./.github/actions/cache-esp-idf + + - name: Register problem matchers + run: | + echo "::add-matcher::.github/workflows/matchers/gcc.json" + echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" + + - name: Check if full clang-tidy scan needed + id: check_full_scan + run: | + . venv/bin/activate + # 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 + else + echo "full_scan=false" >> $GITHUB_OUTPUT + echo "reason=normal" >> $GITHUB_OUTPUT + fi + + - name: Run clang-tidy + # Limited variant scan: only the files carrying that variant's code paths + # (no --all-headers; the comprehensive esp32-idf pass covers the shared tree). + run: | + . venv/bin/activate + if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then + echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" + script/clang-tidy --fix ${{ matrix.options }} + else + echo "Running clang-tidy on changed files only" + script/clang-tidy --fix --changed ${{ matrix.options }} + fi + + - name: Suggested changes + run: script/ci-suggest-changes + 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: | @@ -699,21 +955,43 @@ 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 + - name: Install apt packages (cached) + # A cache hit (seeded on dev by seed-apt-cache) never touches apt, + # so mirror outages cannot hang this PR-only job; the timeout bounds + # the cold path. Packages and version must match seed-apt-cache + # exactly. The action has no --no-install-recommends; same package + # set this job used before #17463. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev - version: 1.0 + packages: libsdl2-dev ccache + version: 1.1 - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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 @@ -744,7 +1022,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 @@ -754,7 +1032,7 @@ jobs: # - This catches pin conflicts and other issues in directly changed code # - Grouped tests use --testing-mode to allow config merging (disables some checks) # - Dependencies are safe to group since they weren't modified in this PR - if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then + if [[ "${{ needs.determine-jobs.outputs.release-pr }}" == "true" ]]; then directly_changed_csv="" echo "Testing components: $components_csv" echo "Target branch: ${{ github.base_ref }} - grouping all components" @@ -765,7 +1043,7 @@ jobs: fi echo "" - # Show disk space before validation (after bind mounts setup) + # Show disk space before validation echo "Disk space before config validation:" df -h echo "" @@ -817,19 +1095,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' + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' env: - ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf - TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner + # 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::ESP32_PLATFORMIO_TEST_COMPONENTS. + TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -837,90 +1123,80 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache ESPHome - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} - - - name: Run native ESP-IDF compile test + - name: Run PlatformIO compile test run: | . venv/bin/activate - # Check if /mnt has more free space than / before bind mounting - # Extract available space in KB for comparison - 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" - - # Only use /mnt if it has more space than / - if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then - echo "Using /mnt for build files (more space available)" - # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there) - 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 - - # Bind mount test build directory to /mnt - 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 - echo "Testing components: $TEST_COMPONENTS" echo "" - # Show disk space before validation (after bind mounts setup) - 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 - with: - path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }} - - pre-commit-ci-lite: - name: pre-commit.ci lite - runs-on: ubuntu-latest + device-builder: + name: Test downstream esphome/device-builder + runs-on: ubuntu-24.04 needs: - common - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') + - determine-jobs + if: needs.determine-jobs.outputs.device-builder == 'true' steps: - - name: Check out code from GitHub - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Restore Python - uses: ./.github/actions/restore-python + - name: Check out esphome (this PR) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 - env: - SKIP: pylint,clang-tidy-hash,ci-custom - - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - if: always() + path: esphome + - name: Check out esphome/device-builder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: esphome/device-builder + ref: main + path: device-builder + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Set up uv + # Mirrors the install shape device-builder's own CI uses + # (esphome/device-builder#192): uv replaces pip for the + # 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + 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. + version: "0.11.15" + - name: Install device-builder + esphome from PR + # Install device-builder with its esphome + test extras + # first so its pinned versions of pytest/etc. land, then + # overlay the PR's esphome so the downstream tests run + # against this PR's Python code. ``--system`` installs into + # the runner's Python instead of a venv. + run: | + uv pip install --system -e './device-builder[esphome,test]' + uv pip install --system -e ./esphome + - name: Run device-builder pytest + # ``-n auto`` runs under pytest-xdist (matches device-builder's + # own CI). No ``--cov`` here -- this is purely a downstream + # smoke check against this PR's esphome code. ``tests/e2e/slow`` + # is excluded: those are real multi-minute toolchain compiles + # (LibreTiny SDK clone, native ESP-IDF install) that device-builder + # runs in its own dedicated jobs, not this smoke check. + working-directory: device-builder + run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow memory-impact-target-branch: name: Build target branch for memory impact @@ -936,7 +1212,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.base_ref }} @@ -1012,7 +1288,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 }} @@ -1036,7 +1312,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') }} @@ -1078,7 +1354,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 }} @@ -1118,14 +1394,14 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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') }} @@ -1187,7 +1463,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -1221,20 +1497,28 @@ jobs: ci-status: name: CI Status runs-on: ubuntu-24.04 + # Listed in the same order the jobs are defined above. One job is + # deliberately left out: "benchmarks" reports through CodSpeed rather than + # this check. needs: - common + - seed-apt-cache + - determine-jobs - ci-custom - pylint + - lint-format - pytest + - codecov-empty-upload - integration-tests + - import-time + - cpp-unit-tests - clang-tidy-single - clang-tidy-nosplit - clang-tidy-split - - determine-jobs - - device-builder + - clang-tidy-esp32-variants - test-build-components-split - - test-native-idf - - pre-commit-ci-lite + - test-esp32-platformio + - device-builder - memory-impact-target-branch - memory-impact-pr-branch - memory-impact-comment @@ -1249,4 +1533,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 013517bde6..bb1d1e2d7a 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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 cd6c1d34c6..38a4b8ff0e 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,13 +29,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a4dd9a92d..b46f9adab6 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 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 e15c61df5e..0000000000 --- a/.github/workflows/dashboard-deprecation-comment.yml +++ /dev/null @@ -1,113 +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 - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 - 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/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 2e96bec1de..104988d7a5 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 5e70117652..e09e9bf2d1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1 + uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index ed0bff9664..3a89c26cd3 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: @@ -29,10 +29,11 @@ jobs: } = require('./.github/scripts/detect-tags.js'); const title = context.payload.pull_request.title; - const author = context.payload.pull_request.user.login; + const user = context.payload.pull_request.user; - // Skip bot PRs (e.g. dependabot) - they have their own title format - if (author === 'dependabot[bot]') { + // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) - + // they have their own title formats. + if (user.type === 'Bot') { return; } @@ -68,14 +69,15 @@ jobs: return; } - // Check for angle brackets not wrapped in backticks. - // Astro docs MDX treats bare < as JSX component opening tags. + // Check for MDX syntax characters not wrapped in backticks. + // Astro docs MDX treats bare `<` as JSX component opening tags and + // bare `{` as JS expressions, so both must be escaped in changelog entries. const stripped = title.replace(/`[^`]*`/g, ''); - if (/[<>]/.test(stripped)) { + if (/[<>{}]/.test(stripped)) { core.setFailed( - 'PR title contains `<` or `>` not wrapped in backticks.\n' + - 'Astro docs MDX interprets bare `<` as JSX components.\n' + - 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support' + 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' + + 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' + + 'Please wrap these characters with backticks, e.g.: [component] Add `` support' ); return; } diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000..cd3b7207b7 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,38 @@ +--- +name: Nightly Dev Release + +# Works out the dated dev tag and starts the release workflow with it, so that +# the release run is named after the tag it builds. A workflow run name is +# fixed when the run starts and cannot read a file or the current date. + +on: + schedule: + - cron: "0 2 * * *" + +permissions: + contents: read # actions/checkout to read the version from esphome/const.py + +jobs: + trigger: + name: Start release build + if: github.repository == 'esphome/esphome' + runs-on: ubuntu-latest + permissions: + contents: read # actions/checkout to read the version from esphome/const.py + actions: write # gh workflow run starts release.yml + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Start the release workflow + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py) + if [[ -z "$VERSION" ]]; then + echo "::error::Could not read __version__ from esphome/const.py" + exit 1 + fi + TAG="${VERSION}$(date --utc '+%Y%m%d')" + echo "Starting release build for ${TAG}" + gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d07c8fe633..d0dee8165c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,23 @@ --- name: Publish Release +# Releases (production and beta) are named after the version they publish. +# Dev builds are named after the dated dev tag, which is passed in by the +# nightly workflow because a run name cannot compute it itself. +run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }} + on: workflow_dispatch: + inputs: + tag: + description: >- + Tag to build. Only supported on dev, where the nightly workflow + uses it. Leave empty to build the version from esphome/const.py + with today's date appended. + required: false + default: "" release: types: [published] - schedule: - - cron: "0 2 * * *" permissions: contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write @@ -20,9 +31,11 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get tag id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} # yamllint disable rule:line-length run: | if [[ "${{ github.event_name }}" = "release" ]]; then @@ -34,12 +47,23 @@ jobs: ENVIRONMENT="production" fi else - TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") - today="$(date --utc '+%Y%m%d')" - TAG="${TAG}${today}" BRANCH=${GITHUB_REF#refs/heads/} + # The nightly workflow passes the finished tag so that the run name + # matches what is built. Without it, work it out here. + TAG="${INPUT_TAG}" + if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then + echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images." + exit 1 + fi + if [[ -z "$TAG" ]]; then + TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p") + today="$(date --utc '+%Y%m%d')" + TAG="${TAG}${today}" + if [[ "$BRANCH" != "dev" ]]; then + TAG="${TAG}-${BRANCH}" + fi + fi if [[ "$BRANCH" != "dev" ]]; then - TAG="${TAG}-${BRANCH}" BRANCH_BUILD="true" ENVIRONMENT="" else @@ -60,9 +84,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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.x" - name: Build @@ -70,7 +94,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip-existing: true @@ -92,22 +116,22 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.11" + python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -168,7 +192,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -178,17 +202,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -212,74 +236,6 @@ jobs: docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) - deploy-ha-addon-repo: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: - - init - - deploy-manifest - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: home-assistant-addon - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - let description = "ESPHome"; - if (context.eventName == "release") { - description = ${{ toJSON(github.event.release.body) }}; - } - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "home-assistant-addon", - workflow_id: "bump-version.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - content: description - } - }) - - deploy-esphome-schema: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Generate a token - id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 - with: - client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} - private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - owner: esphome - repositories: esphome-schema - permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - - - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.generate-token.outputs.token }} - script: | - github.rest.actions.createWorkflowDispatch({ - owner: "esphome", - repo: "esphome-schema", - workflow_id: "generate-schemas.yml", - ref: "main", - inputs: { - version: "${{ needs.init.outputs.tag }}", - } - }) - version-notifier: if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' runs-on: ubuntu-latest @@ -289,7 +245,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} @@ -302,7 +258,7 @@ jobs: with: github-token: ${{ steps.generate-token.outputs.token }} script: | - github.rest.actions.createWorkflowDispatch({ + await github.rest.actions.createWorkflowDispatch({ owner: "esphome", repo: "version-notifier", workflow_id: "notify.yml", diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2e57093bbb..aa31094f81 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -6,61 +6,46 @@ on: - cron: "30 0 * * *" workflow_dispatch: -permissions: - issues: write # actions/stale labels, comments on, and closes stale issues - pull-requests: write # actions/stale labels, comments on, and closes stale pull requests - -concurrency: - group: lock +# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN +# needs no permissions at all. +permissions: {} jobs: stale: if: github.repository_owner == 'esphome' - runs-on: ubuntu-latest - steps: - - name: Stale - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 - with: - debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch - remove-stale-when-updated: true - operations-per-run: 400 + # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome + # GitHub App token so the labels, comments and closures come from + # esphome[bot] instead of github-actions[bot]. + uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main + secrets: + ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + with: + # Live only on dev: a workflow_dispatch from any other branch is a dry run + dry-run: ${{ github.ref != 'refs/heads/dev' }} + days-before-stale: 90 + days-before-close: 7 + stale-label: stale + exempt-label: not-stale + ignored-users: esphbot,codecov-commenter + stale-pr-message: > + There hasn't been any activity on this pull request recently. This + pull request has been automatically marked as stale because of that + and will be closed if no further activity occurs within 7 days. - # The 90 day stale policy for PRs - # - PRs - # - No PRs marked as "not-stale" - # - No Issues (see below) - days-before-pr-stale: 90 - days-before-pr-close: 7 - stale-pr-label: "stale" - exempt-pr-labels: "not-stale" - stale-pr-message: > - There hasn't been any activity on this pull request recently. This - pull request has been automatically marked as stale because of that - and will be closed if no further activity occurs within 7 days. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. - If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev - branch to ensure that it's up to date with the latest changes. + Thank you for your contribution! + stale-issue-message: > + There hasn't been any activity on this issue recently. Due to the + high number of incoming GitHub notifications, we have to clean some + of the old issues, as many of them have already been resolved with + the latest updates. - Thank you for your contribution! + Please make sure to update to the latest ESPHome version and + check if that solves the issue. Let us know if that works for you by + adding a comment 👍 - # The 90 day stale policy for Issues - # - Issues - # - No Issues marked as "not-stale" - # - No PRs (see above) - days-before-issue-stale: 90 - days-before-issue-close: 7 - stale-issue-label: "stale" - exempt-issue-labels: "not-stale" - stale-issue-message: > - There hasn't been any activity on this issue recently. Due to the - high number of incoming GitHub notifications, we have to clean some - of the old issues, as many of them have already been resolved with - the latest updates. - - Please make sure to update to the latest ESPHome version and - check if that solves the issue. Let us know if that works for you by - adding a comment 👍 - - This issue has now been marked as stale and will be closed if no - further activity occurs. Thank you for your contributions. + This issue has now been marked as stale and will be closed if no + further activity occurs. Thank you for your contributions. diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index d27cc0cbec..72987c25b1 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -5,7 +5,7 @@ on: types: [opened, reopened, labeled, unlabeled, synchronize] permissions: - pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr) + pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr) concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} @@ -20,7 +20,7 @@ jobs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; + const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr']; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index c6c829fbb4..9100064176 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + 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 }} @@ -28,39 +28,76 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout Home Assistant - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: home-assistant/core path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" + - name: Set up uv + # An order of magnitude faster than pip on cold boots, with its + # own wheel cache. ``--system`` (below) installs into the + # setup-python interpreter so subsequent ``prek`` / + # ``script/run-in-env.py`` steps find the deps without a + # ``uv run`` prefix. + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + # 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. + version: "0.11.15" + - name: Install Home Assistant run: | - python -m pip install --upgrade pip - pip install -e lib/home-assistant - pip install -r requirements_test.txt pre-commit + uv pip install --system -e lib/home-assistant + uv pip install --system -r requirements.txt -r requirements_test.txt - name: Sync run: | python ./script/sync-device_class.py - - name: Run pre-commit hooks - run: | - python script/run-in-env.py pre-commit run --all-files + - name: Apply prek auto-fixes + # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify + # files. prek exits non-zero whenever a hook touches anything, + # which would otherwise abort the workflow before the auto-fixes + # can flow into the sync PR. + # + # PREK_SKIP: + # - no-commit-to-branch is a local guard against committing on + # dev/release/beta; CI runs on dev by definition, and + # peter-evans/create-pull-request creates the branch itself. + # - pylint surfaces import-error / relative-beyond-top-level + # noise here because this workflow installs only a subset of + # the runtime deps (HA + requirements*.txt); main CI already + # gates pylint on real PRs. + env: + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files || true + + - name: Verify prek clean + # Second pass: re-run all hooks against the now-fixed tree. + # Auto-fixers exit 0 (nothing to change); any remaining failure + # from a check-only hook (flake8 / yamllint / ci-custom) is a + # real issue and fails the workflow loudly. Same PREK_SKIP list as + # above for the same reasons. + env: + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files - name: Commit changes uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "Synchronise Device Classes from Home Assistant" - committer: esphomebot - author: esphomebot + committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> + author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> branch: sync/device-classes delete-branch: true title: "Synchronise Device Classes from Home Assistant" diff --git a/.gitignore b/.gitignore index 4a4a88fd48..fdb75824fb 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,8 @@ CTestTestfile.cmake .gcc-flags.json config/ +# Test fixture config/ directories are tracked (the rule above is the dashboard dir) +!tests/component_tests/**/config/ tests/build/ tests/.esphome/ /.temp-clang-tidy.cpp @@ -141,6 +143,7 @@ tests/.esphome/ sdkconfig.* !sdkconfig.defaults +!sdkconfig.defaults.* .tests/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da5fb94d5e..0ea799aa4d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,12 +6,12 @@ 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 # Ruff version. - rev: v0.15.12 + rev: v0.16.3 hooks: # Run the linter. - id: ruff @@ -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)$ - 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 2139a2b796..f006ee6087 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). @@ -58,6 +57,25 @@ This document provides essential context for AI models interacting with this pro - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Enumerator names: prefix every value of an `enum class` with the enum name converted to + `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare + names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with + these common names (for example the Realtek SDKs used by LibreTiny define + `#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator + before the compiler sees it, breaking the build and clang-tidy on those platforms. + +* **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_`. @@ -179,11 +197,14 @@ This document provides essential context for AI models interacting with this pro my_component_ns = cg.esphome_ns.namespace("my_component") MyComponent = my_component_ns.class_("MyComponent", cg.Component) - CONFIG_SCHEMA = cv.Schema({ - cv.GenerateID(): cv.declare_id(MyComponent), - cv.Required(CONF_KEY): cv.string, - cv.Optional(CONF_PARAM, default=42): cv.int_, - }).extend(cv.COMPONENT_SCHEMA) + CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Required(CONF_KEY): cv.string, + cv.Optional(CONF_PARAM, default=42): cv.int_, + } + ).extend(cv.COMPONENT_SCHEMA) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -217,7 +238,12 @@ This document provides essential context for AI models interacting with this pro - **Sensor:** ```python from esphome.components import sensor - CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s")) + + CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend( + cv.polling_component_schema("60s") + ) + + async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -226,7 +252,10 @@ This document provides essential context for AI models interacting with this pro - **Binary Sensor:** ```python from esphome.components import binary_sensor - CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... }) + + CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...}) + + async def to_code(config): var = await binary_sensor.new_binary_sensor(config) ``` @@ -234,7 +263,10 @@ This document provides essential context for AI models interacting with this pro - **Switch:** ```python from esphome.components import switch - CONFIG_SCHEMA = switch.switch_schema().extend({ ... }) + + CONFIG_SCHEMA = switch.switch_schema().extend({...}) + + async def to_code(config): var = await switch.new_switch(config) ``` @@ -251,10 +283,13 @@ This document provides essential context for AI models interacting with this pro ```python from esphome import automation - CONFIG_SCHEMA = cv.Schema({ - cv.GenerateID(): cv.declare_id(MyComponent), - cv.Optional(CONF_ON_STATE): automation.validate_automation({}), - }).extend(cv.COMPONENT_SCHEMA) + CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MyComponent), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ).extend(cv.COMPONENT_SCHEMA) + async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -304,11 +339,14 @@ This document provides essential context for AI models interacting with this pro ```python TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template()) - CONFIG_SCHEMA = cv.Schema({ - cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( - {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} - ), - }) + CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)} + ), + } + ) + async def to_code(config): for conf in config.get(CONF_ON_TURN_ON, []): @@ -356,7 +394,10 @@ This document provides essential context for AI models interacting with this pro ``` Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. +* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. + * **Configuration Validation:** + * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. * **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`. @@ -369,6 +410,7 @@ This document provides essential context for AI models interacting with this pro .extend(i2c.i2c_device_schema(0x48)) .extend(spi.spi_device_schema(cs_pin_required=True)) ``` + * **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check. ## 5. Key Files & Entrypoints @@ -376,7 +418,7 @@ This document provides essential context for AI models interacting with this pro * **Configuration:** * `pyproject.toml`: Defines the Python project metadata and dependencies. * `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers. - * `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting. + * `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`. * **CI/CD Pipeline:** Defined in `.github/workflows`. * **Static Analysis & Development:** * `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms. @@ -384,7 +426,7 @@ This document provides essential context for AI models interacting with this pro ## 6. Development & Testing Workflow * **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`. -* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`. +* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`. * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. @@ -415,13 +457,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 +486,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`. @@ -457,12 +499,12 @@ This document provides essential context for AI models interacting with this pro 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch). 2. **Make Changes:** Adhere to all coding conventions and patterns. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. - 4. **Lint:** Run `pre-commit` to ensure code is compliant. + 4. **Lint:** Run `prek` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. - 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. + 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** - * Documentation is hosted in the separate `esphome/esphome-docs` repository. + * Documentation is hosted in the separate `esphome/esphome.io` repository. * The contribution workflow is the same as for the codebase. * When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync. @@ -605,6 +647,7 @@ This document provides essential context for AI models interacting with this pro _component_state = [] _use_feature = None + def enable_feature(): global _use_feature _use_feature = True @@ -624,20 +667,24 @@ This document provides essential context for AI models interacting with this pro DOMAIN = "my_component" + @dataclass class MyComponentData: feature_enabled: bool = False item_count: int = 0 items: list[str] = field(default_factory=list) + def _get_data() -> MyComponentData: if DOMAIN not in CORE.data: CORE.data[DOMAIN] = MyComponentData() return CORE.data[DOMAIN] + def request_feature() -> None: _get_data().feature_enabled = True + def add_item(item: str) -> None: _get_data().items.append(item) ``` @@ -645,7 +692,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 @@ -681,7 +728,7 @@ This document provides essential context for AI models interacting with this pro - [ ] Explored non-breaking alternatives - [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++) - [ ] Documented migration path in PR description with before/after examples - - [ ] Updated all internal usage and esphome-docs + - [ ] Updated all internal usage and esphome.io - [ ] Tested backward compatibility during deprecation period * **Deprecation Pattern (C++):** @@ -692,9 +739,37 @@ This document provides essential context for AI models interacting with this pro ``` * **Deprecation Pattern (Python):** + For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates: + ```python + CONFIG_SCHEMA = cv.All( + cv.rename_key( + CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" + ), + cv.Schema({ ... }), + ) + ``` + For other deprecations, warn manually during validation: ```python # Remove before 2026.6.0 if CONF_OLD_KEY in config: - _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") + _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. + +## 10. Code Comments + +Code comments on individual lines should be used only where necessary to flag issues that may not be obvious +on a simple reading of the code. Keep them short (e.g. 1 or 2 lines). + +Function and method comment blocks may include more detail as required to make +calling contracts clear and document parameter usage, but should still be kept concise. + +Avoid redundancy and repetition; comments should never simply restate what the code already says. diff --git a/CODEOWNERS b/CODEOWNERS index f8cdfdc6c6..e1287ca275 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -19,7 +19,6 @@ esphome/components/ac_dimmer/* @glmnet esphome/components/adc/* @esphome/core esphome/components/adc128s102/* @DeerMaximum esphome/components/addressable_light/* @justfalter -esphome/components/ade7880/* @kpfleming esphome/components/ade7953/* @angelnu esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_i2c/* @angelnu @@ -28,7 +27,7 @@ esphome/components/ads1118/* @solomondg1 esphome/components/ags10/* @mak-42 esphome/components/aic3204/* @kbx81 esphome/components/airthings_ble/* @jeromelaban -esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau +esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/alarm_control_panel/* @grahambrown11 @hwstar @@ -70,12 +69,16 @@ esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core esphome/components/bk72xx/* @kuba2k2 +esphome/components/bk72xx_ble/* @Bl00d-B0b +esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b esphome/components/bl0906/* @athom-tech @jesserockz @tarontop esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @dan-s-github @tobias- esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow +esphome/components/ble_device_base/* @Bl00d-B0b esphome/components/ble_nus/* @tomaszduda23 +esphome/components/bluetooth_connection/* @bdraco @jesserockz esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bm8563/* @abmantis esphome/components/bme280_base/* @esphome/core @@ -84,6 +87,7 @@ esphome/components/bme680_bsec/* @trvrnrth esphome/components/bme68x_bsec2/* @kbx81 @neffs esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs esphome/components/bmi160/* @flaviut +esphome/components/bmi270/* @clydebarrow esphome/components/bmp280_base/* @ademuri esphome/components/bmp280_i2c/* @ademuri esphome/components/bmp280_spi/* @ademuri @@ -122,7 +126,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 @@ -139,10 +145,11 @@ esphome/components/dfplayer/* @glmnet esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dht/* @OttoWinter esphome/components/display_menu_base/* @numo68 -esphome/components/dlms_meter/* @SimonFischer04 +esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its +esphome/components/ds248x/* @tomwellnitz esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M @@ -185,6 +192,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 +214,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 @@ -229,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 +esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 @@ -266,6 +276,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 @@ -279,6 +290,7 @@ esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu esphome/components/ld24xx/* @kbx81 +esphome/components/ld6002b/* @hepter esphome/components/ledc/* @OttoWinter esphome/components/libretiny/* @kuba2k2 esphome/components/libretiny_pwm/* @kuba2k2 @@ -286,11 +298,14 @@ esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lm75b/* @beormund +esphome/components/ln882h_ble/* @Bl00d-B0b +esphome/components/ln882h_ble_tracker/* @Bl00d-B0b esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow esphome/components/lps22/* @nagisa +esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr501/* @latonita esphome/components/ltr_als_ps/* @latonita @@ -339,6 +354,7 @@ esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff esphome/components/mmc5983/* @agoode +esphome/components/modbus_client/* @exciton esphome/components/modbus_controller/* @martgras esphome/components/modbus_controller/binary_sensor/* @martgras esphome/components/modbus_controller/number/* @martgras @@ -351,6 +367,7 @@ esphome/components/modbus_server/* @exciton esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_std_check/* @Fabian-Schmidt +esphome/components/motion/* @esphome/core esphome/components/mpl3115a2/* @kbickar esphome/components/mpu6886/* @fabaff esphome/components/ms8607/* @e28eta @@ -364,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw esphome/components/nextion/text_sensor/* @senexcrenshaw esphome/components/nfc/* @jesserockz @kbx81 esphome/components/noblex/* @AGalfra +esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 esphome/components/number/* @esphome/core @@ -379,9 +397,11 @@ esphome/components/pca6416a/* @Mat931 esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman +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 @@ -397,10 +417,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 @@ -417,10 +439,12 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/ring_buffer/* @kahrendt -esphome/components/rp2040/* @jesserockz +esphome/components/router/speaker/* @kahrendt +esphome/components/rp2/* @jesserockz esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz +esphome/components/rp2_ble_tracker/* @bdraco esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet @ximex @@ -441,8 +465,9 @@ 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/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt @@ -451,6 +476,7 @@ esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 esphome/components/sfa30/* @ghsensdev +esphome/components/sfa40/* @NoQuarrel esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw esphome/components/sha256/* @esphome/core @@ -493,6 +519,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 @@ -550,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim esphome/components/tuya/sensor/* @jesserockz esphome/components/tuya/switch/* @jesserockz esphome/components/tuya/text_sensor/* @dentra +esphome/components/tuya/water_heater/* @iago-veiga esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd @@ -557,6 +585,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 @@ -573,6 +602,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 @@ -594,6 +624,7 @@ esphome/components/wk2212_spi/* @DrCoolZic esphome/components/wl_134/* @hobbypunk90 esphome/components/wts01/* @alepee esphome/components/x9c/* @EtienneMD +esphome/components/xdb401/* @RT530 esphome/components/xgzp68xx/* @gcormier esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_lywsd02mmc/* @juanluss31 @@ -607,6 +638,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68 esphome/components/xxtea/* @clydebarrow esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 +esphome/components/zephyr_pwm/* @wiomoc esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zio_ultrasonic/* @kahrendt diff --git a/Doxyfile b/Doxyfile index 7fce941c9b..8f6048b4d8 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.5.0-dev +PROJECT_NUMBER = 2026.9.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/MANIFEST.in b/MANIFEST.in index e426627e8d..1626261fb6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c recursive-include esphome *.py.script recursive-include esphome *.jinja recursive-include esphome LICENSE.txt +recursive-include esphome requirements.txt 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/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..f8afbbde04 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,18 @@ +coverage: + status: + patch: + default: + target: 100% + threshold: 0% + project: + default: + informational: true + +ignore: + - "esphome/components/**/*" + - "esphome/analyze_memory/**/*" + - "tests/integration/**/*" + +comment: + layout: "reach, diff, flags, files" + require_changes: true diff --git a/docker/Dockerfile b/docker/Dockerfile index 540d28be7f..0da8048c57 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,9 @@ ARG BUILD_VERSION=dev -ARG BUILD_OS=alpine -ARG BUILD_BASE_VERSION=2025.04.0 +ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_TYPE=docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker -FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker +FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon ARG BUILD_TYPE FROM base-source-${BUILD_TYPE} AS base @@ -12,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.clibz used by ESP-IDF's idf-component-manager) -RUN if command -v apk > /dev/null; then \ - apk add --no-cache build-base; \ - else \ - apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ - && rm -rf /var/lib/apt/lists/*; \ - fi - ENV PIP_DISABLE_PIP_VERSION_CHECK=1 RUN pip install --no-cache-dir -U pip uv==0.10.1 @@ -32,6 +21,9 @@ RUN \ uv pip install --no-cache-dir \ -r /requirements.txt +# Install the ESPHome Device Builder dashboard. +RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1 + RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/docker/build.py b/docker/build.py index 4d093cf88d..475986e905 100755 --- a/docker/build.py +++ b/docker/build.py @@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] +REGISTRY_GHCR = "ghcr" +REGISTRY_DOCKERHUB = "dockerhub" +REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] + parser = argparse.ArgumentParser() parser.add_argument( @@ -34,6 +38,12 @@ parser.add_argument( parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) +parser.add_argument( + "--registry", + choices=REGISTRIES, + action="append", + help="Restrict to specific registries (default: all). May be passed multiple times.", +) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) @@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) +build_parser.add_argument( + "--no-cache-to", + help="Don't write the build cache (avoids polluting the shared cache)", + action="store_true", +) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @@ -95,11 +110,14 @@ def main(): print("Command failed") sys.exit(1) + registries = args.registry or REGISTRIES + # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: - channel = CHANNEL_DEV + # Custom tag (e.g. a branch name) -- push only the tag itself + channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE @@ -128,11 +146,18 @@ def main(): CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", - }[channel] - cache_img = f"ghcr.io/{params.build_to}:{cache_tag}" + }.get(channel, "cache-dev") + # Cache images live alongside the pushed images; prefer GHCR when it is + # one of the selected registries, otherwise fall back to Docker Hub so a + # registry-restricted build doesn't need GHCR auth. + cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" + cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" - imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push] - imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] + imgs = [] + if REGISTRY_DOCKERHUB in registries: + imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ @@ -155,7 +180,9 @@ def main(): for img in imgs: cmd += ["--tag", img] if args.push: - cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"] + cmd += ["--push"] + if not args.no_cache_to: + cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] @@ -163,20 +190,22 @@ def main(): elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to - targets = [f"{manifest}:{tag}" for tag in tags_to_push] - targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] - # 1. Create manifests + targets = [] + if REGISTRY_DOCKERHUB in registries: + targets += [f"{manifest}:{tag}" for tag in tags_to_push] + if REGISTRY_GHCR in registries: + targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] + # Use buildx imagetools (not `docker manifest`) so the per-arch sources, + # which buildx pushes as single-platform manifest lists, are combined + # and pushed correctly in one step. for target in targets: - cmd = ["docker", "manifest", "create", target] + cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) - # 2. Push manifests - for target in targets: - run_command("docker", "manifest", "push", target) if __name__ == "__main__": diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh index 1b9224244c..c88a78f97e 100755 --- a/docker/docker_entrypoint.sh +++ b/docker/docker_entrypoint.sh @@ -21,10 +21,23 @@ 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 export ESPHOME_BUILD_PATH=/build fi +# The default CMD is "dashboard /config". Route the dashboard to the new +# Device Builder, but pass every other subcommand (compile, run, config, +# logs, ...) straight through to the esphome CLI so direct CLI use keeps working. +if [[ "$1" == "dashboard" ]]; then + shift + exec esphome-device-builder "$@" +fi + exec esphome "$@" diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh deleted file mode 100755 index b990469762..0000000000 --- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/with-contenv bashio -# ============================================================================== -# Installs the latest prerelease of esphome-device-builder when the -# `use_new_device_builder` config option is enabled. -# This is a temporary install-on-boot step until esphome-device-builder -# becomes a direct dependency of esphome. -# ============================================================================== - -if ! bashio::config.true 'use_new_device_builder'; then - exit 0 -fi - -bashio::log.info "Installing latest prerelease of esphome-device-builder..." -if command -v uv > /dev/null; then - uv pip install --system --no-cache-dir --prerelease=allow --upgrade \ - esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -else - pip install --no-cache-dir --pre --upgrade esphome-device-builder || - bashio::exit.nok "Failed installing esphome-device-builder." -fi -bashio::log.info "Installed esphome-device-builder." diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types deleted file mode 100644 index 7c7cdef2d1..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types +++ /dev/null @@ -1,96 +0,0 @@ -types { - text/html html htm shtml; - text/css css; - text/xml xml; - image/gif gif; - image/jpeg jpeg jpg; - application/javascript js; - application/atom+xml atom; - application/rss+xml rss; - - text/mathml mml; - text/plain txt; - text/vnd.sun.j2me.app-descriptor jad; - text/vnd.wap.wml wml; - text/x-component htc; - - image/png png; - image/svg+xml svg svgz; - image/tiff tif tiff; - image/vnd.wap.wbmp wbmp; - image/webp webp; - image/x-icon ico; - image/x-jng jng; - image/x-ms-bmp bmp; - - font/woff woff; - font/woff2 woff2; - - application/java-archive jar war ear; - application/json json; - application/mac-binhex40 hqx; - application/msword doc; - application/pdf pdf; - application/postscript ps eps ai; - application/rtf rtf; - application/vnd.apple.mpegurl m3u8; - application/vnd.google-earth.kml+xml kml; - application/vnd.google-earth.kmz kmz; - application/vnd.ms-excel xls; - application/vnd.ms-fontobject eot; - application/vnd.ms-powerpoint ppt; - application/vnd.oasis.opendocument.graphics odg; - application/vnd.oasis.opendocument.presentation odp; - application/vnd.oasis.opendocument.spreadsheet ods; - application/vnd.oasis.opendocument.text odt; - application/vnd.openxmlformats-officedocument.presentationml.presentation - pptx; - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - xlsx; - application/vnd.openxmlformats-officedocument.wordprocessingml.document - docx; - application/vnd.wap.wmlc wmlc; - application/x-7z-compressed 7z; - application/x-cocoa cco; - application/x-java-archive-diff jardiff; - application/x-java-jnlp-file jnlp; - application/x-makeself run; - application/x-perl pl pm; - application/x-pilot prc pdb; - application/x-rar-compressed rar; - application/x-redhat-package-manager rpm; - application/x-sea sea; - application/x-shockwave-flash swf; - application/x-stuffit sit; - application/x-tcl tcl tk; - application/x-x509-ca-cert der pem crt; - application/x-xpinstall xpi; - application/xhtml+xml xhtml; - application/xspf+xml xspf; - application/zip zip; - - application/octet-stream bin exe dll; - application/octet-stream deb; - application/octet-stream dmg; - application/octet-stream iso img; - application/octet-stream msi msp msm; - - audio/midi mid midi kar; - audio/mpeg mp3; - audio/ogg ogg; - audio/x-m4a m4a; - audio/x-realaudio ra; - - video/3gpp 3gpp 3gp; - video/mp2t ts; - video/mp4 mp4; - video/mpeg mpeg mpg; - video/quicktime mov; - video/webm webm; - video/x-flv flv; - video/x-m4v m4v; - video/x-mng mng; - video/x-ms-asf asx asf; - video/x-ms-wmv wmv; - video/x-msvideo avi; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf deleted file mode 100644 index a1ebb5079a..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -proxy_http_version 1.1; -proxy_ignore_client_abort off; -proxy_read_timeout 86400s; -proxy_redirect off; -proxy_send_timeout 86400s; -proxy_max_temp_file_size 0; - -proxy_set_header Accept-Encoding ""; -proxy_set_header Connection $connection_upgrade; -proxy_set_header Host $http_host; -proxy_set_header Upgrade $http_upgrade; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-NginX-Proxy true; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header Authorization ""; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf deleted file mode 100644 index debdf83a8c..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -root /dev/null; -server_name $hostname; - -client_max_body_size 512m; - -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf deleted file mode 100644 index e6789cbb9b..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf +++ /dev/null @@ -1,8 +0,0 @@ -ssl_protocols TLSv1.2 TLSv1.3; -ssl_prefer_server_ciphers off; -ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; -ssl_session_timeout 10m; -ssl_session_cache shared:SSL:10m; -ssl_session_tickets off; -ssl_stapling on; -ssl_stapling_verify on; diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf deleted file mode 100644 index 8e782bdc88..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf +++ /dev/null @@ -1,3 +0,0 @@ -upstream esphome { - server unix:/var/run/esphome.sock; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf deleted file mode 100644 index 497427596d..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -daemon off; -user root; -pid /var/run/nginx.pid; -worker_processes 1; -error_log /proc/1/fd/1 error; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/includes/mime.types; - - access_log off; - default_type application/octet-stream; - gzip on; - keepalive_timeout 65; - sendfile on; - server_tokens off; - - tcp_nodelay on; - tcp_nopush on; - - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - include /etc/nginx/includes/upstream.conf; - include /etc/nginx/servers/*.conf; -} diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep deleted file mode 100644 index 85ad51be5f..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley) diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl deleted file mode 100644 index 4fb0ca3f90..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl +++ /dev/null @@ -1,28 +0,0 @@ -server { - {{ if not .ssl }} - listen 6052 default_server; - {{ else }} - listen 6052 default_server ssl http2; - {{ end }} - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - {{ if .ssl }} - include /etc/nginx/includes/ssl_params.conf; - - ssl_certificate /ssl/{{ .certfile }}; - ssl_certificate_key /ssl/{{ .keyfile }}; - - # Redirect http requests to https on the same port. - # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/ - error_page 497 https://$http_host$request_uri; - {{ end }} - - # Clear Home Assistant Ingress header - proxy_set_header X-HA-Ingress ""; - - location / { - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl deleted file mode 100644 index 105ddde710..0000000000 --- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl +++ /dev/null @@ -1,18 +0,0 @@ -server { - listen 127.0.0.1:{{ .port }} default_server; - listen {{ .interface }}:{{ .port }} default_server; - - include /etc/nginx/includes/server_params.conf; - include /etc/nginx/includes/proxy_params.conf; - - # Set Home Assistant Ingress header - proxy_set_header X-HA-Ingress "YES"; - - location / { - allow 172.30.32.2; - allow 127.0.0.1; - deny all; - - proxy_pass http://esphome; - } -} diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run index 111157d301..bb36cfcdb4 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run @@ -16,7 +16,7 @@ fi port=$(bashio::addon.ingress_port) -# Wait for NGINX to become available +# Wait for the ESPHome Device Builder to become available bashio::net.wait_for "${port}" "127.0.0.1" 300 config=$(\ diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish index 6e0f8fe23a..da450c25f9 100755 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish +++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish @@ -2,7 +2,7 @@ # shellcheck shell=bash # ============================================================================== # Home Assistant Community Add-on: ESPHome -# Take down the S6 supervision tree when ESPHome dashboard fails +# Take down the S6 supervision tree when ESPHome Device Builder fails # ============================================================================== declare exit_code readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode - fi - [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt -elif [[ "${exit_code_service}" -ne 0 ]]; then - if [[ "${exit_code_container}" -eq 0 ]]; then - echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode - fi - exec /run/s6/basedir/bin/halt -fi diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run deleted file mode 100755 index bb5f52e10c..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ /dev/null @@ -1,23 +0,0 @@ -#!/command/with-contenv bashio -# shellcheck shell=bash -# ============================================================================== -# Community Hass.io Add-ons: ESPHome -# Runs the NGINX proxy -# ============================================================================== - -# The new device builder handles HA ingress itself, so nginx is bypassed. -# Block the longrun forever so s6 keeps the dependency satisfied and does -# not respawn it. -if bashio::config.true 'use_new_device_builder'; then - bashio::log.info "NGINX bypassed: new device builder serves ingress directly." - exec sleep infinity -fi - -bashio::log.info "Waiting for ESPHome dashboard to come up..." - -while [[ ! -S /var/run/esphome.sock ]]; do - sleep 0.5 -done - -bashio::log.info "Starting NGINX..." -exec nginx diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type deleted file mode 100644 index 5883cff0cd..0000000000 --- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type +++ /dev/null @@ -1 +0,0 @@ -longrun diff --git a/docker/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 bca8672917..632d2ba3d0 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2,39 +2,33 @@ import argparse from collections.abc import Callable from contextlib import suppress -from datetime import datetime import functools -import getpass import importlib import logging import os from pathlib import Path import re -import shutil -import subprocess import sys import time -from typing import Protocol - -import argcomplete +from typing import TYPE_CHECKING, Protocol # Note: Do not import modules from esphome.components here, as this would # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. -from esphome import const -import esphome.codegen as cg -from esphome.config import iter_component_configs, read_config, strip_default_ids +from esphome import const, platform_hooks from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, + BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, CONF_DISABLED, + CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -48,14 +42,12 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, + CONF_WIFI, ENV_NOGITIGNORE, - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_ESP32, - PLATFORM_ESP8266, - PLATFORM_RP2040, + KEY_ESP32, + KEY_VARIANT, SECRETS_FILES, Toolchain, ) @@ -63,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log +from esphome.stacktrace import LogLineProcessor from esphome.types import ConfigType from esphome.upload_targets import PortType, get_port_type from esphome.util import ( @@ -78,6 +71,9 @@ from esphome.util import ( safe_print, ) +if TYPE_CHECKING: + import threading + # Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this # module's top level. Every `esphome` invocation — including fast paths # like `esphome version` — pays the cost of what's imported here before @@ -229,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. @@ -240,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 @@ -267,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. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, 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, @@ -290,9 +317,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -304,7 +334,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -316,14 +350,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 @@ -335,7 +362,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) @@ -373,7 +400,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -382,7 +409,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: @@ -466,10 +493,23 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" - from esphome.components.mqtt import CONF_DISCOVER_IP - if CONF_MQTT not in CORE.config: return False # Default Enabled @@ -484,17 +524,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: @@ -503,11 +548,17 @@ def has_resolvable_address() -> bool: if has_ip_address(): return True + # 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): + return True + if has_mdns(): 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: @@ -519,11 +570,48 @@ def has_name_add_mac_suffix() -> bool: def mqtt_get_ip( - config: ConfigType, username: str, password: str, client_id: str + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, ) -> list[str]: from esphome import mqtt - return mqtt.get_esphome_device_ip(config, username, password, client_id) + return mqtt.get_esphome_device_ip( + config, username, password, client_id, stop_event=stop_event + ) + + +def _add_network_device(device: str, network_devices: list[str]) -> None: + """Append a device to the list, expanding it through ``CORE.address_cache``. + + If the hostname is already in the address cache (e.g. populated by mDNS + discovery), substitute the cached IPs so aioesphomeapi doesn't open its + own Zeroconf to re-resolve it. Duplicates are dropped. + """ + if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): + network_devices.extend(addr for addr in cached if addr not in network_devices) + elif device not in network_devices: + network_devices.append(device) + + +def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]: + """Split the device list into direct addresses and an MQTT-lookup flag. + + Direct addresses are expanded through ``CORE.address_cache`` and deduped + the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings + are not resolved, only reported via the returned bool so the caller can + defer the broker lookup. + """ + network_devices: list[str] = [] + has_mqtt_lookup = False + for device in devices: + if get_port_type(device) in _MQTT_PORT_TYPES: + has_mqtt_lookup = True + else: + _add_network_device(device, network_devices) + return network_devices, has_mqtt_lookup def _resolve_network_devices( @@ -556,41 +644,47 @@ def _resolve_network_devices( if port_type in _MQTT_PORT_TYPES: # Only resolve MQTT once, even if multiple MQTT entries if not mqtt_resolved: - try: - mqtt_ips = mqtt_get_ip( - config, args.username, args.password, args.client_id - ) - # pylint can't infer mqtt_get_ip's return through its - # lazy ``from esphome import mqtt`` import, so it flags - # the genexpr below. - network_devices.extend( - addr - for addr in mqtt_ips # pylint: disable=not-an-iterable - if addr not in network_devices - ) - except EsphomeError as err: - _LOGGER.warning( - "MQTT IP discovery failed (%s), will try other devices if available", - err, - ) + mqtt_ips = _mqtt_get_ip_or_warn( + config, args.username, args.password, args.client_id + ) + network_devices.extend( + addr for addr in mqtt_ips if addr not in network_devices + ) mqtt_resolved = True continue - # If the hostname is already in the address cache (e.g. populated by - # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't - # open its own Zeroconf to re-resolve it. - if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)): - network_devices.extend( - addr for addr in cached if addr not in network_devices - ) - elif device not in network_devices: - # Regular network address or IP - add if not already present - network_devices.append(device) + _add_network_device(device, network_devices) return network_devices +def _mqtt_get_ip_or_warn( + config: ConfigType, + username: str, + password: str, + client_id: str, + stop_event: "threading.Event | None" = None, +) -> list[str]: + """Look up the device IP via MQTT, returning [] with a warning on failure. + + This owns the failure policy for MQTT IP discovery on paths that have + other addresses to fall back on: a broker problem must not abort the + operation. Also used as the deferred resolver handed to ``run_logs``, + where it runs in a worker thread. + """ + try: + return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event) + except EsphomeError as err: + _LOGGER.warning( + "MQTT IP discovery failed (%s), will try other devices if available", + err, + ) + return [] + + def run_miniterm(config: ConfigType, port: str, args) -> int: + from datetime import datetime + from aioesphomeapi import LogParser import serial @@ -603,18 +697,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: return 1 _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) - process_stacktrace = None - - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - process_stacktrace = getattr(module, "process_stacktrace") - except (AttributeError, ImportError): - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) - - backtrace_state = False + # Decoder resolution, crash isolation, and disable-after-failure + # all live in LogLineProcessor, shared with the API log path. + processor = LogLineProcessor(config, CORE.target_platform) ser = serial.Serial() ser.baudrate = baud_rate ser.port = port @@ -638,7 +723,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: chunk = ser.read(ser.in_waiting or 1) if not chunk: continue - time_ = datetime.now() + time_ = datetime.now().astimezone() milliseconds = time_.microsecond // 1000 time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" @@ -654,11 +739,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: "utf8", "backslashreplace" ) safe_print(parser.parse_line(line, time_str)) - - if process_stacktrace is not None: - backtrace_state = process_stacktrace( - config, line, backtrace_state - ) + processor.process_line(line) except serial.SerialException: _LOGGER.error("Serial port closed!") return 0 @@ -673,15 +754,19 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: def _wrap_to_code(name, comp, yaml_util): + import esphome.codegen as cg + coro = coroutine(comp.to_code) @functools.wraps(comp.to_code) async def wrapped(conf): cg.add(cg.LineComment(f"{name}:")) if comp.config_schema is not None: - conf_str = yaml_util.dump(conf) + # sort_keys: voluptuous fills defaults in set order, so an + # unsorted dump would churn main.cpp and relink every run + conf_str = yaml_util.dump(conf, sort_keys=True) conf_str = conf_str.replace("//", "") - # remove tailing \ to avoid multi-line comment warning + # remove trailing \ to avoid multi-line comment warning conf_str = conf_str.replace("\\\n", "\n") cg.add(cg.LineComment(indent(conf_str))) await coro(conf) @@ -694,6 +779,11 @@ def _wrap_to_code(name, comp, yaml_util): def write_cpp(config: ConfigType) -> int: from esphome import writer + # Refresh the storage sidecar and clean an incompatible previous build + # before regenerating any sources. This may full-wipe the build dir, so it + # has to run before write_cpp_file writes src/. + writer.update_storage_json() + if not get_bool_env(ENV_NOGITIGNORE): writer.write_gitignore() @@ -703,6 +793,7 @@ def write_cpp(config: ConfigType) -> int: def generate_cpp_contents(config: ConfigType) -> None: from esphome import yaml_util + from esphome.config import iter_component_configs _LOGGER.info("Generating C++ source...") @@ -733,6 +824,20 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + # Keep this gate here, NOT in config validation: device-builder needs + # `esphome config` to keep succeeding with placeholders so onboarding can run. + if CONF_WIFI in config: + from esphome.components.wifi import check_placeholder_credentials + + check_placeholder_credentials(config) + + # Keep this here, NOT in codegen: config-hash and --only-generate must keep + # working on machines that cannot run the toolchain. + if CORE.is_esp8266: + from esphome.components.esp8266 import check_rosetta + + check_rosetta() + # NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py # If you change this format, update the regex in that script as well _LOGGER.info("Compiling app... Build path: %s", CORE.build_path) @@ -752,6 +857,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() + from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS + + try: + if toolchain.get_idedata() is None: + _LOGGER.warning("No idedata was generated for this build") + except IDEDATA_BEST_EFFORT_ERRORS as err: + # The firmware already built; an idedata failure must not fail + # a successful build. + _LOGGER.warning( + "Could not generate idedata: %s (IDE, clang-tidy, and " + "memory-analysis data will be unavailable for this build)", + err, + ) + _LOGGER.debug("Idedata failure detail", exc_info=True) else: from esphome.platformio import toolchain @@ -786,7 +905,7 @@ def _check_and_emit_build_info() -> None: # Read build_info from JSON try: - with open(build_info_json_path, encoding="utf-8") as f: + with build_info_json_path.open(encoding="utf-8") as f: build_info = json.load(f) except (OSError, json.JSONDecodeError) as e: _LOGGER.debug("Failed to read build_info: %s", e) @@ -880,9 +999,10 @@ def upload_using_esptool( mcu = "esp8266" if CORE.is_esp32: - from esphome.components.esp32 import get_esp32_variant - - mcu = get_esp32_variant().lower() + # Same lookup as esp32.get_esp32_variant(), read directly so the + # serial upload path does not import the esp32 package; both the + # validator and the warm-cache apply_to_core populate this key. + mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower() line_callbacks: list[Callable[[str], str | None]] = [] if ( @@ -936,12 +1056,14 @@ def upload_using_esptool( def upload_using_platformio(config: ConfigType, port: str) -> int: + import shutil + from esphome.platformio import toolchain # 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" @@ -973,6 +1095,8 @@ def upload_using_picotool(config: ConfigType) -> int: the mass storage copy approach that causes "disk not ejected properly" warnings on macOS. """ + import subprocess + from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -1048,7 +1172,7 @@ def _wait_for_serial_port( def _port_found() -> bool: if port is not None: if os.name == "posix": - return os.path.exists(port) + return Path(port).exists() return any(p.path == port for p in get_serial_ports()) ports = get_serial_ports() if known_ports is not None: @@ -1079,6 +1203,8 @@ def check_permissions(port: str): "the USB cable can be used for data and is not a power-only cable." ) if not (os.access(port, os.R_OK | os.W_OK)): + import getpass + raise EsphomeError( "You do not have read or write permission on the selected serial port. " "To resolve this issue, you can add your user to the dialout group " @@ -1091,12 +1217,11 @@ def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: host = devices[0] - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "upload_program")(config, args, host): - return 0, host - except AttributeError: - pass + platform_upload = platform_hooks.get_platform_hook( + CORE.target_platform, "upload_program" + ) + if platform_upload is not None and platform_upload(config, args, host): + return 0, host port_type = get_port_type(host) @@ -1126,10 +1251,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 @@ -1247,25 +1372,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1342,13 +1465,25 @@ def _validate_bootloader_binary(binary: Path) -> None: ) +def _should_subscribe_states(args: ArgsProtocol) -> bool: + """Determine whether entity state changes should be shown in log output. + + The ``--states``/``--no-states`` command line flags take precedence. When + neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls + the behavior, defaulting to showing states. + """ + states = getattr(args, "states", None) + if states is not None: + return states + return get_bool_env("ESPHOME_LOG_STATES", True) + + def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if getattr(module, "show_logs")(config, args, devices): - return 0 - except AttributeError: - pass + platform_show_logs = platform_hooks.get_platform_hook( + CORE.target_platform, "show_logs" + ) + if platform_show_logs is not None and platform_show_logs(config, args, devices): + return 0 if "logger" not in config: raise EsphomeError("Logger is not configured!") @@ -1362,17 +1497,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int return run_miniterm(config, port, args) # Check if we should use API for logging - # Resolve MQTT magic strings to actual IP addresses - if has_api() and ( - network_devices := _resolve_network_devices(devices, config, args) - ): - from esphome.components.api.client import run_logs + if has_api(): + network_devices, has_mqtt_lookup = _split_network_devices(devices) + mqtt_resolver = None + if has_mqtt_lookup: + if network_devices: + # Addresses are already known, so don't block startup on the + # MQTT broker lookup; hand it to run_logs as a deferred + # resolver that runs in the background and feeds discovered + # addresses into the running log client, keeping MQTT as a + # fallback for when the known addresses are stale (e.g. DHCP + # reassigned the IP). + mqtt_resolver = functools.partial( + _mqtt_get_ip_or_warn, + config, + args.username, + args.password, + args.client_id, + ) + else: + # The MQTT lookup is the only way to find the device; resolve + # it up front since the client needs an address to start with. + network_devices = _resolve_network_devices(devices, config, args) + if network_devices: + from esphome.api_client import run_logs - return run_logs( - config, - network_devices, - subscribe_states=not getattr(args, "no_states", False), - ) + return run_logs( + config, + network_devices, + subscribe_states=_should_subscribe_states(args), + mqtt_resolver=mqtt_resolver, + ) if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging(): from esphome import mqtt @@ -1381,6 +1536,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") @@ -1400,21 +1562,86 @@ def command_wizard(args: ArgsProtocol) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import yaml_util + from esphome.config import strip_default_ids - if not CORE.verbose: + if getattr(args, "no_defaults", False): + user_config = getattr(config, "user_config", None) + if user_config is None: + _LOGGER.warning( + "--no-defaults requested but the user-only config snapshot is " + "unavailable; falling back to the validated configuration." + ) + else: + config = user_config + elif not CORE.verbose: config = strip_default_ids(config) output = yaml_util.dump(config, args.show_secrets) - # add the console decoration so the front-end can hide the secrets if not args.show_secrets: - output = re.sub( - r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output - ) + output = _redact_with_legacy_fallback(output) if not CORE.quiet: safe_print(output) _LOGGER.info("Configuration is valid!") return 0 +# Legacy substring redaction fallback for unmigrated schemas; removed in +# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips +# values that already render themselves: ``\033[8m`` (SensitiveStr wrap), +# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line +# block; first line is structural). The fragment must either start the +# field name or follow ``_`` so the warning names a real field; this avoids +# false positives like ``monkey:`` matching the ``key`` fragment. +_LEGACY_REDACTION_RE = re.compile( + r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: " + r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)" +) +_LEGACY_REDACTION_REMOVAL = "2026.12.0" + + +def _redact_with_legacy_fallback(output: str) -> str: + unmarked: set[str] = set() + # Track the top-level ``substitutions:`` block. Its keys are arbitrary + # user-chosen names with no schema validator, so the ``cv.sensitive(...)`` + # migration named in the warning can't be applied to them. Their values are + # still redacted, but emitting the (unactionable) deprecation warning would + # only confuse users. + in_substitutions = False + + lines = output.split("\n") + for i, line in enumerate(lines): + # A non-indented, non-blank line is a top-level key that opens or + # closes the substitutions block. + if line and not line[0].isspace(): + in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:") + m = _LEGACY_REDACTION_RE.search(line) + if m is None: + continue + key = m.group("key") + if not in_substitutions: + # Public keys (e.g. wireguard's peer_public_key) are not secret; + # redacting them and telling maintainers to mark them cv.sensitive + # would be wrong on both counts. Substitution keys are user-named + # with no schema behind them, so anything secret-shaped there + # (public or not) stays conservatively redacted. + if "public" in key.split("_"): + continue + unmarked.add(key) + lines[i] = ( + f"{line[: m.start()]}{key}: " + f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}" + ) + output = "\n".join(lines) + for key in sorted(unmarked): + _LOGGER.warning( + "Field '%s' is being redacted by a legacy substring heuristic. " + "Mark this field's schema validator with cv.sensitive(...) for " + "deterministic redaction; the heuristic will be removed in %s.", + key, + _LEGACY_REDACTION_REMOVAL, + ) + return output + + def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None: # generating code might modify config, so it must be done in order to generate # a hash that will match what was generated when compiling and then running @@ -1534,10 +1761,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() @@ -1579,7 +1803,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: from esphome import writer try: - writer.clean_build() + writer.clean_build(full=True) except OSError as err: _LOGGER.error("Error deleting build files: %s", err) return 1 @@ -1588,7 +1812,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None: def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None: - from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator + from esphome.bundle import ConfigBundleCreator creator = ConfigBundleCreator(config) @@ -1620,9 +1844,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( @@ -1699,6 +1927,21 @@ def command_update_all(args: ArgsProtocol) -> int | None: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json + if CORE.using_toolchain_esp_idf: + # Native ESP-IDF derives idedata from the build's compile_commands.json, + # so the configuration must already be compiled. + from esphome.espidf import toolchain as espidf_toolchain + + idedata = espidf_toolchain.get_idedata() + if idedata is None: + _LOGGER.error( + "No idedata available; compile the configuration first", + ) + return 1 + + print(json.dumps(idedata, indent=2) + "\n") + return 0 + if not CORE.using_toolchain_platformio: _LOGGER.error( "The idedata command is not compatible with %s toolchain", @@ -1792,7 +2035,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ram_report = ram_analyzer.generate_report() print() print(ram_report) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.warning("RAM strings analysis failed: %s", e) return 0 @@ -1804,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_name = args.name for c in new_name: if c not in ALLOWED_NAME_CHARS: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{c}' is an invalid character for names. Valid characters are: " @@ -1817,7 +2060,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: yaml = yaml_util.load_yaml(CORE.config_path) if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: - print( + safe_print( color( AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." ) @@ -1864,7 +2107,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) > 1 ): - print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")) + safe_print( + color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename") + ) return 1 new_raw = re.sub( @@ -1882,7 +2127,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: # ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # would otherwise just re-flash the same hostname). if new_name == old_name: - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -1892,7 +2137,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: new_path: Path = CORE.config_dir / (new_name + ".yaml") if new_path.resolve() == CORE.config_path.resolve(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"'{new_name}' is already the device's name.", @@ -1900,7 +2145,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) return 1 if new_path.exists(): - print( + safe_print( color( AnsiFore.BOLD_RED, f"Cannot rename: {new_path} already exists. " @@ -1908,7 +2153,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: ) ) return 1 - print( + safe_print( f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" ) print() @@ -1917,7 +2162,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) if rc != 0: - print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) + safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes.")) new_path.unlink() return 1 @@ -1943,7 +2188,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None: if CORE.config_path != new_path: CORE.config_path.unlink() - print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) + safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) print() return 0 @@ -1980,6 +2225,29 @@ SIMPLE_CONFIG_ACTIONS = [ ] +def _add_states_args(parser: argparse.ArgumentParser) -> None: + """Add mutually exclusive ``--states``/``--no-states`` flags to a parser. + + When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable + controls whether entity state changes are shown (defaulting to showing them). + """ + states_group = parser.add_mutually_exclusive_group() + states_group.add_argument( + "--states", + dest="states", + action="store_true", + default=None, + help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).", + ) + states_group.add_argument( + "--no-states", + dest="states", + action="store_false", + default=None, + help="Do not show entity state changes in log output.", + ) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2072,6 +2340,12 @@ def parse_args(argv): parser_config.add_argument( "--show-secrets", help="Show secrets in output.", action="store_true" ) + parser_config.add_argument( + "--no-defaults", + help="Only output the user-supplied configuration without " + "schema defaults applied.", + action="store_true", + ) parser_config_hash = subparsers.add_parser( "config-hash", help="Calculate the hash of the configuration." @@ -2156,11 +2430,7 @@ def parse_args(argv): help="Reset the device before starting serial logs.", default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), ) - parser_logs.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_logs) parser_discover = subparsers.add_parser( "discover", @@ -2192,11 +2462,7 @@ def parse_args(argv): "--no-logs", help="Disable starting logs.", action="store_true" ) - parser_run.add_argument( - "--no-states", - action="store_true", - help="Do not show entity state changes in log output.", - ) + _add_states_args(parser_run) parser_run.add_argument( "--reset", @@ -2242,50 +2508,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") @@ -2352,7 +2599,12 @@ def parse_args(argv): # a deprecation warning). arguments = argv[1:] - argcomplete.autocomplete(parser) + # argcomplete only does anything when the shell-completion machinery + # invokes us with _ARGCOMPLETE set; skip the import otherwise. + if "_ARGCOMPLETE" in os.environ: + import argcomplete + + argcomplete.autocomplete(parser) if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS: args, unknown_args = parser.parse_known_args(arguments) @@ -2363,6 +2615,49 @@ def parse_args(argv): return parser.parse_args(arguments) +def _warn_if_source_tree_mismatch() -> None: + """Warn when the checkout the user is standing in is not the one being run. + + An editable install records one absolute path, so a venv shared between git + worktrees (or reused after a checkout is copied or renamed) keeps importing + the tree it was installed from. Every command then silently runs, and + compiles, sources the user is not looking at. Only fires inside a checkout, + so ordinary installs never see it. + """ + try: + cwd = Path.cwd() + except OSError: + return # working directory is gone; a diagnostic must not break startup + for candidate in (cwd, *cwd.parents): + if (candidate / "esphome" / "__main__.py").is_file(): + standing_in = candidate.resolve() + break + else: + return # not inside a checkout; nothing to compare against + + running = Path(__file__).resolve().parent.parent + # Both sides are resolved, so on a case-sensitive filesystem this matches + # plain equality. samefile() compares device and inode, which additionally + # covers a case-insensitive filesystem (macOS) reaching one directory by + # differently cased paths. Falls back to equality if either path is gone. + try: + same = standing_in.samefile(running) + except OSError: + same = standing_in == running + if same: + return + + _LOGGER.warning( + "Running ESPHome from a different checkout than the one you are in:\n" + " running from: %s\n" + " you are in: %s\n" + "The installed esphome resolves to the first, so its sources are used.\n" + "Run 'python -m esphome' from the second to use that one instead.", + running, + standing_in, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2380,11 +2675,8 @@ 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) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: @@ -2416,10 +2708,11 @@ def run_esphome(argv): return 0 # Bundle support: if the configuration is a .esphomebundle, extract it - # and rewrite conf_path to the extracted YAML config. - from esphome.bundle import is_bundle_path, prepare_bundle_for_compile + # and rewrite conf_path to the extracted YAML config. The suffix check + # stays inline so the ordinary run never imports esphome.bundle. + if conf_path.name.lower().endswith(BUNDLE_EXTENSION): + from esphome.bundle import prepare_bundle_for_compile - if is_bundle_path(conf_path): _LOGGER.info("Extracting config bundle %s...", conf_path) conf_path = prepare_bundle_for_compile(conf_path) # Update the argument so downstream code sees the extracted path @@ -2434,20 +2727,56 @@ def run_esphome(argv): # Commands that don't need fresh external components: logs just connects # to the device, and clean is about to delete the build directory. skip_external = args.command in ("logs", "clean") - config = read_config( - dict(args.substitution) if args.substitution else {}, - skip_external_update=skip_external, + command_line_substitutions = dict(args.substitution) if args.substitution else {} + + # Fast path for upload/logs: reuse the validated-config cache the + # last compile wrote. Falls back to read_config when missing/stale. + # Skipped when -s overrides are passed, since the cache was written + # against the previous substitution set. + config: ConfigType | None = None + cache_write_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions ) - if config is None: - return 2 + # An explicit --toolchain must re-run the per-platform validators, so + # gate only the cache read; the refresh below saves the result unless + # the sidecar records a different toolchain. + cache_read_eligible = cache_write_eligible and args.toolchain is None + if cache_read_eligible: + from esphome.compiled_config import load_compiled_config + + config = load_compiled_config(conf_path) + if config is not None: + _LOGGER.info( + "Loaded validated config cache for %s, skipping validation.", + conf_path.name, + ) + + cache_missed = config is None + if cache_missed: + from esphome.config import read_config + + config = read_config( + command_line_substitutions, + skip_external_update=skip_external, + # Snapshot only needed by `esphome config --no-defaults`. + snapshot_user_config=getattr(args, "no_defaults", False), + ) + if config is None: + return 2 CORE.config = config - # Fallback for platforms whose validators didn't set the toolchain - # (only the esp32 component reads esp32.framework.toolchain). All - # other platforms only support PlatformIO today. + # The cache fast path skips validation, and legacy sidecars lack the + # toolchain field. Must run before the cache refresh below. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO + # Refresh the cache so the next upload/logs hits the fast path + # instead of re-running read_config. + if cache_write_eligible and cache_missed: + from esphome.compiled_config import save_compiled_config_and_sidecar + + save_compiled_config_and_sidecar(config) + if args.command not in POST_CONFIG_ACTIONS: safe_print(f"Unknown command {args.command}") return 1 diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 8f1f39e1d6..ab20e4d076 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -6,6 +6,7 @@ from collections import defaultdict from collections.abc import Callable import heapq from operator import itemgetter +from pathlib import Path import sys from typing import TYPE_CHECKING @@ -19,6 +20,7 @@ from . import ( RAM_SECTIONS, MemoryAnalyzer, ) +from .toolchain import find_elf_path, find_idedata_path, idedata_candidates if TYPE_CHECKING: from . import ComponentMemory @@ -509,7 +511,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" ) - for i, (symbol, demangled, size) in enumerate(large_core_symbols): + for i, (_symbol, demangled, size) in enumerate(large_core_symbols): # Core symbols only track (symbol, demangled, size) without section info, # so we don't show section labels here lines.append( @@ -601,7 +603,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):" ) - for i, (symbol, demangled, size, section) in enumerate(large_symbols): + for i, (_symbol, demangled, size, section) in enumerate(large_symbols): lines.append( f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}" ) @@ -640,7 +642,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append( f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):" ) - for symbol, demangled, size, section in large_ram_syms[:10]: + for _symbol, demangled, size, section in large_ram_syms[:10]: # Format section label consistently by stripping leading dot section_label = section.lstrip(".") if section else "" display_name = _format_pstorage_name(demangled) @@ -699,7 +701,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): content = "\n".join(lines) if output_file: - with open(output_file, "w", encoding="utf-8") as f: + with Path(output_file).open("w", encoding="utf-8") as f: f.write(content) else: print(content) @@ -737,7 +739,6 @@ def main(): # Load build directory import json - from pathlib import Path from esphome.platformio.toolchain import IDEData @@ -759,45 +760,25 @@ def main(): print(f"Error: {build_path} is not a directory", file=sys.stderr) sys.exit(1) - # Find firmware.elf - elf_file = None - for elf_candidate in [ - build_path / "firmware.elf", - build_path / ".pioenvs" / build_path.name / "firmware.elf", - ]: - if elf_candidate.exists(): - elf_file = str(elf_candidate) - break - - if not elf_file: - print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr) + elf_path = find_elf_path(build_path) + if not elf_path: + print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr) sys.exit(1) - - # Find idedata.json - check current directory first, then home - device_name = build_path.name - idedata_candidates = [ - Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json", - Path.home() / ".esphome" / "idedata" / f"{device_name}.json", - ] + elf_file = str(elf_path) idedata = None - for idedata_path in idedata_candidates: - if not idedata_path.exists(): - continue + if idedata_path := find_idedata_path(build_path): try: - with open(idedata_path, encoding="utf-8") as f: + with idedata_path.open(encoding="utf-8") as f: raw_data = json.load(f) idedata = IDEData(raw_data) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) - break except (json.JSONDecodeError, OSError) as e: print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) if not idedata: - print( - f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})", - file=sys.stderr, - ) + searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) + print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata) analyzer.analyze() diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py index 8999108b51..7dbd6d4f63 100644 --- a/esphome/analyze_memory/demangle.py +++ b/esphome/analyze_memory/demangle.py @@ -154,7 +154,7 @@ def batch_demangle( failed_count = 0 for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines + symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True ): # Add back any prefix that was removed demangled = _restore_symbol_prefix(prefix, stripped, demangled) 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/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py index 3a8a5f7be4..19041ac807 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os from pathlib import Path import subprocess from typing import TYPE_CHECKING @@ -24,6 +23,78 @@ TOOLCHAIN_PREFIXES = [ ] +def find_elf_path(build_path: Path) -> Path | None: + """Locate the firmware ELF inside an ESPHome build directory. + + The layout depends on the toolchain that produced the build, so try each + known one in turn. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the ELF file, or None if no known layout matches + """ + name = build_path.name + for candidate in ( + # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # to build/firmware.elf (see espidf.toolchain.create_elf_copy) + build_path / "build" / "firmware.elf", + # PlatformIO + build_path / "firmware.elf", + build_path / ".pioenvs" / name / "firmware.elf", + # LibreTiny uses raw_firmware.elf + build_path / "raw_firmware.elf", + build_path / ".pioenvs" / name / "raw_firmware.elf", + # Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2 + build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf", + build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf", + ): + if candidate.is_file(): + return candidate + return None + + +def idedata_candidates(build_path: Path) -> list[Path]: + """Return the idedata locations searched for a build directory, in order. + + Exposed so a caller reporting "not found" can name the paths it tried + without keeping its own copy of the list. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + The candidate idedata JSON paths, most specific first + """ + name = build_path.name + return [ + # In .pioenvs for test builds + build_path / ".pioenvs" / name / "idedata.json", + # Both toolchains cache it in the data dir, which holds this build dir: + # /idedata/.json next to /build/ + build_path.parent.parent / "idedata" / f"{name}.json", + # Regular builds, invoked from the config dir or from anywhere + Path.cwd() / ".esphome" / "idedata" / f"{name}.json", + Path.home() / ".esphome" / "idedata" / f"{name}.json", + ] + + +def find_idedata_path(build_path: Path) -> Path | None: + """Locate the idedata JSON belonging to an ESPHome build directory. + + Args: + build_path: Path to an ESPHome build directory + + Returns: + Path to the idedata JSON, or None if it was not found + """ + for candidate in idedata_candidates(build_path): + if candidate.is_file(): + return candidate + return None + + def _find_in_platformio_packages(tool_name: str) -> str | None: """Search for a tool in PlatformIO package directories. @@ -37,7 +108,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None: Full path to the tool or None if not found """ # Get PlatformIO packages directory - platformio_home = Path(os.path.expanduser("~/.platformio/packages")) + platformio_home = Path("~/.platformio/packages").expanduser() if not platformio_home.exists(): return None diff --git a/esphome/api_client.py b/esphome/api_client.py new file mode 100644 index 0000000000..fb41075de8 --- /dev/null +++ b/esphome/api_client.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +import logging +import threading +from typing import TYPE_CHECKING, Any +import warnings + +# Suppress protobuf version warnings +with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", category=UserWarning, message=".*Protobuf gencode version.*" + ) + from aioesphomeapi import APIClient, parse_log_message + from aioesphomeapi.log_runner import async_run + +from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__ +from esphome.core import CORE +from esphome.stacktrace import LogLineProcessor +from esphome.util import safe_print + +if TYPE_CHECKING: + from collections.abc import Callable + + from aioesphomeapi.api_pb2 import ( + SubscribeLogsResponse, # pylint: disable=no-name-in-module + ) + + +_LOGGER = logging.getLogger(__name__) + + +async def async_run_logs( + config: dict[str, Any], + addresses: list[str], + subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, +) -> None: + """Run the logs command in the event loop. + + If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt + has no asyncio support on Windows) concurrently with the connection + attempts to ``addresses``, and any addresses it discovers are fed into + the running client. It owns its own failure handling (returning [] when + discovery fails) and must honor the ``threading.Event`` it is passed so + teardown is not delayed by the lookup's wait window; the initial broker + connect itself is only bounded by the socket timeout. + """ + from datetime import datetime + + conf = config["api"] + name = config["esphome"]["name"] + port: int = int(conf[CONF_PORT]) + noise_psk: str | None = None + if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): + noise_psk = key + + _LOGGER.info( + "Starting log output from %s using esphome API", " or ".join(addresses) + ) + + cli = APIClient( + addresses[0], # Primary address for compatibility + port, + "", # Password auth removed in 2026.1.0 + client_info=f"ESPHome Logs {__version__}", + noise_psk=noise_psk, + addresses=addresses, # Pass all addresses for automatic retry + provide_time=False, + ) + + # Decoder resolution policy lives in LogLineProcessor. + processor = LogLineProcessor(config, CORE.target_platform) + + mqtt_task: asyncio.Task[None] | None = None + mqtt_stop_event = threading.Event() + + def _cancel_mqtt_discovery() -> None: + """Stop the broker lookup once a connection has been established. + + Its answer is only useful while still disconnected: after that it + either duplicates the connected address or arrives too late to + matter, so don't keep an idle broker session open for it. + """ + mqtt_stop_event.set() + if mqtt_task is not None and not mqtt_task.done(): + mqtt_task.cancel() + + async def _resolve_mqtt_addresses() -> None: + """Discover the device address via the MQTT broker in the background.""" + try: + mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event) + if not mqtt_ips: + _LOGGER.debug( + "MQTT discovery %s", + "aborted" if mqtt_stop_event.is_set() else "found no addresses", + ) + return + if cli.add_addresses(mqtt_ips): + _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips)) + else: + _LOGGER.debug( + "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips) + ) + except Exception: # pylint: disable=broad-except + # A background task failure would otherwise stay invisible for + # the whole session and only re-raise at teardown + _LOGGER.exception("MQTT address discovery failed") + + def on_log(msg: SubscribeLogsResponse) -> None: + """Handle a new log message.""" + time_ = datetime.now().astimezone() + message: bytes = msg.message + text = message.decode("utf8", "backslashreplace") + nanoseconds = time_.microsecond // 1000 + timestamp = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" + ) + for parsed_msg in parse_log_message(text, timestamp): + # safe_print handles the dashboard \033 escaping and falls back + # to backslashreplace encoding on stdouts that can't represent + # the wifi signal-bar block characters (Windows redirected + # cp1252 pipe). + safe_print(parsed_msg) + for raw_line in text.splitlines(): + processor.process_line(raw_line) + + # Safe to fall back to plaintext here only for this diagnostics use + # case: the stream is one-way from device to client, and this code + # never accepts commands or acts on any message the device sends. + # An on-path attacker could still both inject fabricated log lines + # and passively read the device's log output (and any state data + # delivered when subscribe_states is enabled), so this does lose + # confidentiality as well as authentication/integrity. That tradeoff + # is acceptable for operator-visible logs, which aioesphomeapi also + # warns may come from an unverified device. Never mirror this opt-in + # for any connection that sends data to the device or uses Home + # Assistant actions. + stop = await async_run( + cli, + on_log, + name=name, + subscribe_states=subscribe_states, + allow_plaintext_fallback=True, + # A top-level ``deep_sleep:`` block means the device is only awake + # briefly; cap the reconnect backoff so a wake window is not missed. + deep_sleep="deep_sleep" in config, + on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None, + ) + try: + # Don't start (or keep) the broker lookup if a connection already + # succeeded; the stop event doubles as the not-needed-anymore latch + # and get_esphome_device_ip returns immediately when it is set. + if mqtt_resolver is not None and not mqtt_stop_event.is_set(): + mqtt_task = asyncio.create_task(_resolve_mqtt_addresses()) + await asyncio.Event().wait() + finally: + try: + if mqtt_task is not None: + # Unblock the worker thread first so it can't hold up + # loop.shutdown_default_executor() for the full lookup timeout. + mqtt_stop_event.set() + # Give the worker a moment to exit through its own error + # handling; cancelling first would race out a late failure. + done, _ = await asyncio.wait([mqtt_task], timeout=1.0) + if not done: + mqtt_task.cancel() + # return_exceptions keeps a CancelledError from the cancel() + # above from re-raising here and jumping over the stop() below. + # The task handles Exception itself, so only a BaseException + # escape (e.g. SystemExit from the worker) can land here. + (result,) = await asyncio.gather(mqtt_task, return_exceptions=True) + if isinstance(result, BaseException) and not isinstance( + result, asyncio.CancelledError + ): + _LOGGER.error("MQTT address discovery failed", exc_info=result) + finally: + # Must run even if a second cancellation lands mid-cleanup above + await stop() + + +def run_logs( + config: dict[str, Any], + addresses: list[str], + subscribe_states: bool = True, + mqtt_resolver: Callable[[threading.Event], list[str]] | None = None, +) -> None: + """Run the logs command.""" + with suppress(KeyboardInterrupt): + asyncio.run( + async_run_logs( + config, + addresses, + subscribe_states=subscribe_states, + mqtt_resolver=mqtt_resolver, + ) + ) diff --git a/esphome/arduino8266/__init__.py b/esphome/arduino8266/__init__.py new file mode 100644 index 0000000000..8f403a8553 --- /dev/null +++ b/esphome/arduino8266/__init__.py @@ -0,0 +1,9 @@ +"""Native (PlatformIO-free) build support for the ESP8266 Arduino core. + +This package downloads the Arduino ESP8266 core and the xtensa-lx106 +toolchain, generates a ninja build for them plus the ESPHome sources, and +drives the build directly — the ESP8266 equivalent of ``esphome.espidf``. + +Deliberately importable without the esp8266 component to avoid circular +imports; the component wires these modules in via lazy imports. +""" diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py new file mode 100644 index 0000000000..1edbe4b36f --- /dev/null +++ b/esphome/arduino8266/framework.py @@ -0,0 +1,164 @@ +"""Download and install the Arduino ESP8266 core, toolchain, and ninja. + +Artifacts land in a machine-global cache (shared across projects, like the +ESP-IDF install in ``esphome.espidf.framework``): + + /arduino8266/frameworks// framework-arduinoespressif8266 + /arduino8266/toolchains// toolchain-xtensa (gcc 10.3) + +Packages come from the PlatformIO registry (identical bits to the PlatformIO +backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes +from PATH or the ninja PyPI wheel. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import NamedTuple + +from esphome.build_helpers.ccache import ccache_defaults_env +from esphome.build_helpers.ninja import find_ninja +from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path +from esphome.core import EsphomeError, Version +from esphome.framework_helpers import str_to_lst_of_str +from esphome.platformio.registry import install_package, prefetch_packages + +FRAMEWORK_PACKAGE = "framework-arduinoespressif8266" +TOOLCHAIN_PACKAGE = "toolchain-xtensa" +# gcc 10.3, the toolchain Arduino core 3.x builds with; the build +# generator's compile flags are tuned to it. +TOOLCHAIN_VERSION = "2.100300.220621" + +ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str( + os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "") +) +ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str( + os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "") +) + + +def get_arduino8266_tools_path() -> Path: + # Machine-global so all projects share one install; see + # espidf.framework.get_idf_tools_path for the location rationale. + return tools_cache_path(*ARDUINO8266_TOOLS_CACHE) + + +# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the +# encoder below cannot name 3.0.0/3.0.1 either (see its docstring) +MIN_FRAMEWORK_VERSION = Version(3, 1, 1) + + +def framework_package_version(ver: Version) -> str: + """Map an Arduino core version to its registry package version (3.1.2 -> + 3.30102.0; the leading 3 is the package major). + + Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor + at MIN_FRAMEWORK_VERSION. + """ + if ver.major > 3: + raise EsphomeError( + f"Arduino core {ver} is not supported yet; " + "the newest known core series is 3.x" + ) + if ver <= Version(2, 6, 2): + # Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same + # boundary as _format_framework_arduino_version's era guard) + raise EsphomeError( + f"Arduino core {ver} uses an older package encoding than this " + "helper implements (newer than 2.6.2)" + ) + return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + + +def get_framework_path(package_version: str) -> Path: + return get_arduino8266_tools_path() / "frameworks" / package_version + + +def get_toolchain_path() -> Path: + return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION + + +class InstalledPaths(NamedTuple): + """Locations of the installed framework, toolchain, and ninja binary.""" + + framework: Path + toolchain: Path + ninja: Path + + +def check_and_install(framework_version: Version) -> InstalledPaths: + """Ensure framework, toolchain, and ninja are installed; return their paths.""" + if framework_version < MIN_FRAMEWORK_VERSION: + # Config validation enforces this too; keep the module honest when + # called directly. + raise EsphomeError( + f"The native toolchain requires the Arduino core " + f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}" + ) + # Probe the cheap local dependency before ~110 MB of downloads + ninja_path = find_ninja() + package_version = framework_package_version(framework_version) + framework_path = get_framework_path(package_version) + downloads_dir = get_arduino8266_tools_path() / "downloads" + toolchain_path = get_toolchain_path() + # One spec per package: the prefetch and the installs must agree + specs = ( + ( + FRAMEWORK_PACKAGE, + package_version, + framework_path, + ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, + ("cores/esp8266", "tools/sdk", "libraries"), + ), + ( + TOOLCHAIN_PACKAGE, + TOOLCHAIN_VERSION, + toolchain_path, + ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, + # xtensa-lx106-elf pins the target: every gcc package has a bin/ + ("bin", "xtensa-lx106-elf"), + ), + ) + # Fetch both archives at once; the installs below verify and extract + prefetch_packages([spec[:4] for spec in specs], downloads_dir) + for name, version, dest, mirrors, expect in specs: + install_package(name, version, dest, mirrors, downloads_dir, expect=expect) + return InstalledPaths( + framework=framework_path, toolchain=toolchain_path, ninja=ninja_path + ) + + +def toolchain_tool(toolchain_path: Path, name: str) -> Path: + """Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...). + + The single owner of the ``bin/xtensa-lx106-elf-`` layout and the + Windows suffix, so a toolchain package bump touches one spot. + """ + suffix = ".exe" if os.name == "nt" else "" + return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}" + + +def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]: + env = os.environ.copy() + # Drop empty entries: a trailing separator from an absent PATH would + # make the shell search the current directory for tools + parts = [ + str(toolchain_path / "bin"), + *filter(None, env.get("PATH", "").split(os.pathsep)), + ] + env["PATH"] = os.pathsep.join(parts) + env.update(ccache_env(ccache)) + return env + + +def ccache_env(ccache: str | None) -> dict[str, str]: + """Return ccache settings for the build subprocess (not os.environ). + + ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None + when disabled. Values the user already set in the environment are + respected. + """ + if ccache is None: + return {} + return ccache_defaults_env(get_arduino8266_tools_path() / "ccache") diff --git a/esphome/async_thread.py b/esphome/async_thread.py index 7be3c83a9a..3296d65af6 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -11,46 +11,136 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable +from itertools import count +import logging import threading -from typing import Generic, TypeVar +from typing import cast -_T = TypeVar("_T") +_LOGGER = logging.getLogger(__name__) + +# How long the orphan watcher waits for an abandoned coroutine before giving +# up, so a hung operation does not park a watcher thread forever. +ORPHAN_WAIT_TIMEOUT = 300.0 -class AsyncThreadRunner(threading.Thread, Generic[_T]): - """Run an async coroutine in a daemon thread and expose its result. +_runner_ids = count(1) - The runner catches all exceptions from the coroutine and stores them in - ``exception`` so ``event`` is always set — this prevents callers waiting - on ``event`` from hanging forever when the coroutine crashes. - Typical usage:: +class AsyncDispatchTimeout(TimeoutError): + """The caller stopped waiting; the coroutine was abandoned. - runner = AsyncThreadRunner(lambda: my_coro(arg)) - runner.start() - if not runner.event.wait(timeout=5.0): - ... # timed out - if runner.exception is not None: - raise runner.exception - result = runner.result + A subclass so callers can tell the dispatcher's own expiry apart from a + ``TimeoutError`` raised inside the coroutine, while existing + ``except TimeoutError`` handlers keep working. """ - def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None: - super().__init__(daemon=True) + +class AsyncThreadRunner[T](threading.Thread): + """Run an async coroutine in a daemon thread and expose its result. + + ``event`` is always set, even when the coroutine crashes, so waiters + never hang; ``completed`` distinguishes a delivered result (even a + legitimate ``None``) from a coroutine that never finished. Prefer + :func:`run_async`; use this class directly only when a failure should + degrade to a default value instead of raising. + """ + + def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: + super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}") self._coro_factory = coro_factory - self.result: _T | None = None + self.result: T | None = None self.exception: BaseException | None = None + self.completed = False self.event = threading.Event() async def _runner(self) -> None: try: self.result = await self._coro_factory() - except Exception as exc: # pylint: disable=broad-except - # Capture all exceptions so ``event`` is always set — otherwise a - # crash would hang the waiter forever. + # Distinguishes a delivered result from "never ran", since None + # is a valid result value. + self.completed = True + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except + # Capture everything, including BaseException — otherwise a + # cancellation or SystemExit would leave ``exception`` unset and + # waiters would mistake the empty ``result`` for success. self.exception = exc finally: self.event.set() def run(self) -> None: - asyncio.run(self._runner()) + try: + asyncio.run(self._runner()) + except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except + # asyncio.run itself can fail before _runner executes (e.g. loop + # creation under fd exhaustion); record it so waiters never hang. + # A failure during loop cleanup after the coroutine completed + # must not clobber the delivered result, hence the guard. + if self.exception is None and not self.completed: + self.exception = exc + else: + _LOGGER.debug( + "Event loop teardown failed after outcome recorded", + exc_info=True, + ) + finally: + self.event.set() + + +def run_async[T]( + coro_factory: Callable[[], Awaitable[T]], + timeout: float | None = None, + on_orphan: Callable[[T], None] | None = None, +) -> T: + """Run a coroutine in a daemon-thread event loop and return its result. + + Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish + within ``timeout`` seconds; the thread is abandoned and exits with the + interpreter. If the abandoned coroutine later produces a result, + ``on_orphan`` (if given) is called with it so resources such as a + connected socket can be released; delivery is best effort and bounded + by ``ORPHAN_WAIT_TIMEOUT``. + """ + runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory) + runner.start() + if not runner.event.wait(timeout): + + def _cleanup() -> None: + if not runner.event.wait(ORPHAN_WAIT_TIMEOUT): + # The one state where a resource can genuinely leak; leave + # a trace so a recurring hang is attributable. + _LOGGER.info( + "Orphan watcher gave up after %.0fs; a late result may leak", + ORPHAN_WAIT_TIMEOUT, + ) + return + if not runner.completed: + # The only place an abandoned thread's real error surfaces; + # without it a late failure hides behind the TimeoutError. + # INFO, not DEBUG: it fires at most once per abandoned + # operation and the cause may not reproduce on a rerun. + _LOGGER.info( + "Abandoned async operation failed", + exc_info=runner.exception, + ) + return + if (result := runner.result) is None: + return + if on_orphan is None: + _LOGGER.debug("Discarding late result; no on_orphan handler") + return + try: + on_orphan(result) + except Exception: # pylint: disable=broad-except + # INFO, not DEBUG: a failed release means a real leak, and + # it fires at most once per abandoned operation. + _LOGGER.info("Error releasing orphaned result", exc_info=True) + + threading.Thread( + target=_cleanup, daemon=True, name="async-orphan-cleanup" + ).start() + raise AsyncDispatchTimeout("Timed out waiting for async operation") + if (exc := runner.exception) is not None: + raise exc + if not runner.completed: + raise RuntimeError("Async operation finished without a result or an exception") + return cast("T", runner.result) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index dfe2d72b9d..2ef89cf595 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -1,64 +1,175 @@ """ESP-IDF direct build generator for ESPHome.""" import json +import logging from pathlib import Path -from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32 import ( + get_esp32_variant, + get_excluded_builtin_components, + get_managed_component_require_names, + idf_version, +) +import esphome.config_validation as cv from esphome.core import CORE +from esphome.espidf import variant_to_idf_target +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 -from esphome.writer import update_storage_json + +_LOGGER = logging.getLogger(__name__) + +# Replaces the IDF default C++ standard (-std=gnu++2b appended to +# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via +# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), +# i.e. after IDF appends its default and before the options are consumed, and +# applies project-wide like PlatformIO build_unflags. +CPP_STANDARD_TEMPLATE = """\ +idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS) +list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") +list(APPEND esphome_cxx_compile_options "-std={standard}") +idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" def get_available_components() -> list[str] | None: - """Get list of available ESP-IDF components from project_description.json. + """List the built-in ESP-IDF components from ``project_description.json``. - Returns only internal ESP-IDF components, excluding external/managed - components (from idf_component.yml). + Only components below its ``idf_path/components`` count, which leaves out + ``src``, IDF-managed components, converted PIO libs and project local + ones such as the Arduino ``component_stubs``. Returns ``None`` if the + build dir or ``project_description.json`` isn't ready yet. """ + if CORE.build_path is None: + return None project_desc = Path(CORE.build_path) / "build" / "project_description.json" if not project_desc.exists(): return None try: - with open(project_desc, encoding="utf-8") as f: + with project_desc.open(encoding="utf-8") as f: data = json.load(f) - - component_info = data.get("build_component_info", {}) - - result = [] - for name, info in component_info.items(): - # Exclude our own src component - if name == "src": - continue - - # Exclude managed/external components - comp_dir = info.get("dir", "") - if "managed_components" in comp_dir: - continue - - result.append(name) - - return result - except (json.JSONDecodeError, OSError): + root = (Path(data["idf_path"]) / "components").resolve() + result = [ + name + for name, info in data.get("build_component_info", {}).items() + if (comp_dir := info.get("dir")) + and Path(comp_dir).resolve().is_relative_to(root) + ] + except (json.JSONDecodeError, KeyError, OSError) as err: + _LOGGER.debug("Could not read %s: %s", project_desc, err) return None + if not result: + _LOGGER.warning("No ESP-IDF components found under %s", root) + return result def has_discovered_components() -> bool: - """Check if we have discovered components from a previous configure.""" - return get_available_components() is not None + """Check if a previous configure discovered any built-in components.""" + return bool(get_available_components()) -def get_project_cmakelists() -> str: - """Generate the top-level CMakeLists.txt for ESP-IDF project.""" - # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) - variant = get_esp32_variant() - idf_target = variant.lower().replace("-", "") +def _cmake_quote(value: str) -> str: + """Quote a cmake arg value for a set() line. add_cmake_arg rejects + whitespace, quotes, and '$', so only backslashes need escaping.""" + escaped = value.replace("\\", "\\\\") + return f'"{escaped}"' - # Extract compile definitions from build flags (-DXXX -> XXX) - compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")] + +def get_project_cmakelists( + minimal: bool = False, builtin_components: list[str] | None = None +) -> str: + """Generate the top-level CMakeLists.txt for ESP-IDF project. + + When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS`` + since ``project_description.json`` may be stale on the first write. + ``builtin_components`` supplies the discovered list (from the cache) + instead of reading it from ``project_description.json``. + """ + idf_target = variant_to_idf_target(get_esp32_variant()) + + # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and + # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get + # --format=raw because the legacy mode doesn't support it. + size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" + + # Project-wide compile options: -D defines and -W warning flags (skip + # -Wl, linker flags — those go on the src component via + # target_link_options below). Emitted via idf_build_set_property so the + # flags propagate to every IDF component (including managed ones like + # 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 = get_project_compile_flags() extra_compile_options = "\n".join( - f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)' - for compile_def in compile_defs + 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 + else "" + ) + + # CMake variables registered via cg.add_cmake_arg(). Emitted before + # include(project.cmake) so values like EXCLUDE_COMPONENTS are already + # set when project.cmake seeds the component list, and on minimal + # (discovery) writes too so excluded components never register. + cmake_args = "\n".join( + f"set({name} {_cmake_quote(value)})" + for name, value in sorted(CORE.cmake_args.items()) + ) + + # Per-project list exposed as a CMake variable so converted PIO libs + # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking + # project-specific names into their cached CMakeLists. + # + # Emit via idf_build_set_property (not plain set()) so the value is + # serialised into build_properties.temp.cmake and visible to IDF's + # early requirements-expansion pass (component_get_requirements.cmake + # runs as a separate CMake script invocation that doesn't load the + # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_ + # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty). + managed_components_property = "\n".join( + f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" + for name in get_managed_component_require_names() + ) + + # Built-in IDF components exposed via our own property (not IDF's + # __COMPONENT_REQUIRES_COMMON, which would append them to every + # component's REQUIRES including real IDF components). Referenced by + # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped + # on minimal writes because project_description.json may be stale. + # Excluded components are dropped here as well: a stale + # project_description.json from a build without exclusions may still + # list them, and requiring an excluded component pulls it back into + # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the + # two can never disagree within one generated file. + builtin_components_property = ( + "" + if minimal + else "\n".join( + f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)" + for name in sorted( + set( + builtin_components + if builtin_components is not None + else get_available_components() or [] + ).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";")) + ) + ) ) return f"""\ @@ -84,16 +195,26 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1) set(IDF_TARGET {idf_target}) set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) +{cmake_args} + include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cpp_standard_options} + +{cxx_compile_options} + {extra_compile_options} +{managed_components_property} + +{builtin_components_property} + project({CORE.name}) # Emit raw JSON size data for ESPHome to read post-build. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD - COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw + COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} @@ -102,44 +223,56 @@ add_custom_command( """ -def get_component_cmakelists(minimal: bool = False) -> str: - """Generate the main component CMakeLists.txt.""" - idf_requires = [] if minimal else (get_available_components() or []) - requires_str = " ".join(idf_requires) +def get_component_cmakelists() -> str: + """Generate the main component CMakeLists.txt. - # Extract compile options (-W flags, excluding linker flags) - compile_opts = [ - flag - for flag in CORE.build_flags - if flag.startswith("-W") and not flag.startswith("-Wl,") - ] - compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else "" - - # Extract linker options (-Wl, flags) - 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 "" + REQUIRES pulls in the discovered built-in IDF components via the + project-level variables set in the top-level CMakeLists. + """ + # 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 = get_project_link_flags() + link_opts_str = "\n ".join(link_opts) if link_opts else "" return f"""\ # Auto-generated by ESPHome -file(GLOB_RECURSE app_sources - "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" - "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" - "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" - "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" -) +# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test +# runs that reuse the build dir don't compile stale source paths. It's +# invalid in script mode (cmake -P), which is how IDF's +# component_get_requirements.cmake includes us, so skip it there. +if(CMAKE_SCRIPT_MODE_FILE) + file(GLOB_RECURSE app_sources + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" + ) +else() + file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++" + "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++" + "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c" + ) +endif() idf_component_register( SRCS ${{app_sources}} INCLUDE_DIRS "." "esphome" - REQUIRES {requires_str} -) - -# Apply C++ standard -target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) - -# ESPHome compile options -target_compile_options(${{COMPONENT_LIB}} PUBLIC - {compile_opts_str} + REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) # ESPHome linker options @@ -149,24 +282,31 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC """ -def write_project(minimal: bool = False) -> None: +def write_project( + minimal: bool = False, builtin_components: list[str] | None = None +) -> None: """Write ESP-IDF project files.""" - # Refresh /storage/.yaml.json so the dashboard's - # /info and /downloads endpoints can locate the build (they 404 - # otherwise). This mirrors the PlatformIO build-gen path's call - # in build_gen/platformio.py:write_ini(). - update_storage_json() mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) # Write top-level CMakeLists.txt write_file_if_changed( CORE.relative_build_path("CMakeLists.txt"), - get_project_cmakelists(), + get_project_cmakelists(minimal=minimal, builtin_components=builtin_components), ) # Write component CMakeLists.txt in src/ write_file_if_changed( CORE.relative_src_path("CMakeLists.txt"), - get_component_cmakelists(minimal=minimal), + get_component_cmakelists(), + ) + + # Snapshot the exclusion set so has_outdated_files() can trigger a + # discovery reconfigure when it changes. Excluded components never + # register in project_description.json, so re-including one (e.g. a + # config gains mqtt) requires a fresh discovery pass before the + # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + write_file_if_changed( + CORE.relative_build_path("exclude_components.esphomeinternal"), + ";".join(get_excluded_builtin_components()), ) diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 30dbb69d86..0a12d344a0 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -1,7 +1,7 @@ from esphome.const import __version__ from esphome.core import CORE from esphome.helpers import mkdir_p, read_file, write_file_if_changed -from esphome.writer import find_begin_end, update_storage_json +from esphome.writer import find_begin_end INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============" @@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str: return content +# All -std= variants a platform/framework may set by default, in both the GNU +# and strict dialects; unflagged so the cg.set_cpp_standard() value is the +# only standard left in the build. +CPP_STD_VARIANTS = [ + f"{prefix}{year}" + for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c") + for prefix in ("gnu++", "c++") +] + + def get_ini_content(): CORE.add_platformio_option( "lib_deps", [x.as_lib_dep for x in CORE.platformio_libraries.values()] + ["${common.lib_deps}"], ) + if CORE.cpp_standard: + for variant in CPP_STD_VARIANTS: + if variant != CORE.cpp_standard: + CORE.add_build_unflag(f"-std={variant}") + CORE.add_build_flag(f"-std={CORE.cpp_standard}") # Sort to avoid changing build flags order CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) @@ -48,6 +63,17 @@ def get_ini_content(): # Add extra script for C++ flags CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"]) + # Add CMake args. A user-supplied value (str or list) is deliberately + # replaced; this option was always overwritten at FINAL priority. + if CORE.cmake_args: + CORE.add_platformio_option( + "board_build.cmake_extra_args", + " ".join( + f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items()) + ), + replace=True, + ) + content = "[platformio]\n" content += f"description = ESPHome {__version__}\n" @@ -58,7 +84,6 @@ def get_ini_content(): def write_ini(content): - update_storage_json() path = CORE.relative_build_path("platformio.ini") if path.is_file(): @@ -94,7 +119,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/build_helpers/__init__.py b/esphome/build_helpers/__init__.py new file mode 100644 index 0000000000..df956a2509 --- /dev/null +++ b/esphome/build_helpers/__init__.py @@ -0,0 +1 @@ +"""Build helpers shared by the native (non-PlatformIO) toolchains.""" diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py new file mode 100644 index 0000000000..5b5c7f247f --- /dev/null +++ b/esphome/build_helpers/ccache.py @@ -0,0 +1,92 @@ +"""Shared ccache policy for build backends: env-knob parsing, binary +resolution, and default ``CCACHE_*`` values.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs +from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS + +_LOGGER = logging.getLogger(__name__) + + +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs.""" + return tool_version_runs( + ccache, + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ) + + +def parse_enable_env(name: str) -> bool | None: + """Strictly parse an on/off environment knob; None when unset or invalid. + + ``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only + 1/true/yes/on and 0/false/no/off count; anything else warns and reads + as unset so the caller's default policy applies. + """ + raw = os.environ.get(name) + if raw is None: + return None + lowered = raw.strip().lower() + if not lowered: + # ENV KNOB= (Docker/CI) has always read as a disable + return False + if lowered in TRUTHY_ENV_STRINGS: + return True + if lowered in FALSY_ENV_STRINGS: + return False + _LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw) + return None + + +def resolve_ccache_path() -> str | None: + """The ccache binary to wrap compiles with, or None when disabled. + + An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the + Windows extended-length prefix is stripped before probing (#18399). + """ + import shutil + + explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE") + if explicit is False: + return None + ccache = shutil.which("ccache") + if ccache is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return None + ccache = strip_win_long_path_prefix(ccache) + if not explicit and not _ccache_runs(ccache): + return None + return ccache + + +def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: + """Default ``CCACHE_*`` values for a build subprocess (not os.environ). + + Values the user already set in the environment are respected. Depend + mode is on: both native backends emit depfiles (-MMD / CMake), which + keeps cache-miss overhead low. + """ + from esphome.core import CORE + + # An unset build_path means the env was built before preload; fail loudly + # rather than silently drop CCACHE_BASEDIR. + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the build environment" + ) + defaults = { + "CCACHE_DIR": str(cache_dir), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + return {k: v for k, v in defaults.items() if k not in os.environ} diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py new file mode 100644 index 0000000000..038fe64970 --- /dev/null +++ b/esphome/build_helpers/idedata.py @@ -0,0 +1,380 @@ +"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``. + +PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native +toolchains have no such command, but each build produces a +``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's +compdb tool otherwise). This module turns that file into the same fields +consumers (IDE integration, clang-tidy) expect: + + {cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}} +""" + +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path +import shlex +import subprocess + +from esphome.core import EsphomeError +from esphome.helpers import write_file + +# Everything idedata generation may raise after a successful link; idedata +# is a bonus artifact, so consumers warn instead of failing the build +IDEDATA_BEST_EFFORT_ERRORS = ( + EsphomeError, + LookupError, + OSError, + RuntimeError, + ValueError, +) + +_LOGGER = logging.getLogger(__name__) + +# C++ translation-unit suffixes used to identify ESPHome source files. +_CXX_SUFFIXES = (".cpp", ".cc") +# Suffixes of input/output files that appear bare on the command line (and so +# must not be mistaken for compiler flags). +_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s") +# Path marker identifying an ESPHome source translation unit. +_ESPHOME_SRC_MARKER = "/src/esphome/" + + +def _is_esphome_src(file: str) -> bool: + """Whether ``file`` is an ESPHome C++ translation unit; normalized to + ``/`` first since Windows compile DBs use backslashes.""" + return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith( + _CXX_SUFFIXES + ) + + +def _split_command(command: str) -> list[str]: + r"""Tokenize a compile_commands.json / response-file command string. + + On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``. + ESP-IDF's compile_commands.json there mixes two backslash conventions in one + string: literal path separators in the compiler path (``C:\Users\...g++.exe``, + no quote follows) and shell quote-escaping in -D defines (``-DVER=\"1.2.3\"``). + Only the real Windows parser — where a backslash escapes solely a following + quote — handles both, and it is the exact tokenizer the compiler is launched + with. ``shlex`` cannot: POSIX mode eats the path separators, and disabling + its escape mangles the defines. + """ + if os.name != "nt": + return shlex.split(command) + + import ctypes + from ctypes import wintypes + + # CommandLineToArgvW("") returns the current process name, not []; guard it + # so an empty response file tokenizes the same as it would via shlex. + if not command.strip(): + return [] + + CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + argc = ctypes.c_int() + argv = CommandLineToArgvW(command, ctypes.byref(argc)) + if not argv: # pragma: no cover + raise ctypes.WinError() + try: + return [argv[i] for i in range(argc.value)] + finally: + ctypes.windll.kernel32.LocalFree(argv) + + +def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: + """Inline any ``@response-file`` arguments (paths relative to ``directory``). + + GCC response files embed flags that must be expanded so GCC-only flags + inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as + ``@file`` clang would read them and choke. + """ + out: list[str] = [] + for tok in tokens: + if tok.startswith("@"): + rf = Path(tok[1:]) + if not rf.is_absolute(): + rf = directory / rf + try: + out.extend( + _expand_response_files( + _split_command(rf.read_text(encoding="utf-8")), directory + ) + ) + continue + except OSError as err: + # Keep the literal token if the file can't be read, but log it + # so the (otherwise opaque) downstream clang failure is traceable. + _LOGGER.warning("Could not read response file %s: %s", rf, err) + out.append(tok) + return out + + +def _pick_entry(entries: list[dict]) -> dict: + """Pick a representative ESPHome C++ TU; all share the same component + flags/defines.""" + for entry in entries: + if _is_esphome_src(entry["file"]): + return entry + for entry in entries: + if entry["file"].endswith(_CXX_SUFFIXES): + return entry + raise ValueError("no C++ translation unit found in compile_commands.json") + + +# Compiler launchers that may prefix a compile command; a closed launcher +# denylist beats enumerating compiler names, an open set. +_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"}) + + +def _is_launcher(token: str) -> bool: + return Path(token).stem.lower() in _LAUNCHER_STEMS + + +def parse_entry( + entry: dict, launcher: str | None = None +) -> tuple[str, list[str], list[str], list[str]]: + """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" + directory = Path(entry["directory"]) + tokens = _expand_response_files(_split_command(entry["command"]), directory) + + def _include(raw: str) -> str: + # Resolve against the entry's ``directory`` so cached idedata works + # from any cwd; emit forward slashes to match the JSON's own entries + raw = raw.strip() + if raw and not Path(raw).is_absolute(): + raw = os.path.normpath(directory / raw) + return raw.replace("\\", "/") + + # A launcher-wrapped command ("ccache g++ ...") names the compiler second + if launcher is not None and tokens[:1] == [launcher]: + tokens = tokens[1:] + if not tokens: + # An empty command, or one that was only the launcher; fail by name + raise ValueError(f"empty compile command for {entry.get('file')}") + if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"): + # Stale DB built with a launcher this run no longer configures; the + # real compiler is the next token + _LOGGER.warning("Stripping unconfigured launcher %s", tokens[0]) + tokens = tokens[1:] + # token0 is the compiler path; the rest of the command already uses forward + # slashes on Windows, so normalize it too for a consistent idedata file. + cxx_path = tokens[0].replace("\\", "/") + # Enforced here so no caller can record ccache as the compiler + reject_launcher_compiler(cxx_path) + defines: list[str] = [] + includes: list[str] = [] + cxx_flags: list[str] = [] + + it = iter(tokens[1:]) + for tok in it: + if tok in ("-c", "-o"): + next(it, None) # drop the flag and its argument (input/output) + elif tok.startswith("-D"): + # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single + # quoted arg with a space after -D) that some flags arrive as. + defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip()) + elif tok.startswith("-I"): + includes.append(_include(tok[2:] if len(tok) > 2 else next(it, ""))) + elif tok == "-isystem": + includes.append(_include(next(it, ""))) + elif tok.startswith("-isystem"): + includes.append(_include(tok[len("-isystem") :])) + elif tok in ("-MT", "-MF", "-MQ"): + next(it, None) # dependency-file flag + its argument + elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")): + pass # dependency-generation flags, no argument + elif tok.endswith(_INPUT_FILE_SUFFIXES): + pass # input/output files + else: + cxx_flags.append(tok) + return cxx_path, defines, includes, cxx_flags + + +def get_toolchain_includes(cxx_path: str) -> list[str]: + """Query the compiler for its builtin ``#include <...>`` search dirs.""" + result = subprocess.run( + [cxx_path, "-E", "-x", "c++", "-", "-v"], + input="", + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False, + close_fds=False, + ) + includes: list[str] = [] + capture = False + for line in result.stderr.splitlines(): + if "#include <...> search starts here:" in line: + capture = True + continue + if "End of search list." in line: + break + if capture: + includes.append(line.strip()) + if result.returncode != 0 or not includes: + raise RuntimeError( + f"Could not query builtin include dirs from {cxx_path} " + f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}" + ) + return includes + + +def _cc_path_from_cxx(cxx_path: str) -> str: + """Derive the C compiler path from the C++ compiler path. + + compile_commands.json only names the C++ compiler, but consumers reach the + rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of + ``cc_path``, so they need the ``gcc``-suffixed name. + """ + stem, suffix = ( + (cxx_path[: -len(".exe")], ".exe") + if cxx_path.endswith(".exe") + else (cxx_path, "") + ) + # Rewrite the program name only when it is g++ itself, or a toolchain + # prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc. + # Requiring a separator before the "g++" keeps names that merely end in + # those three characters intact: "clang++" must not become "clangcc". + head = stem[: -len("g++")] + if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))): + stem = f"{head}gcc" + return f"{stem}{suffix}" + + +def _cache_usable(cached: object) -> bool: + """Check a cached idedata dict against the guarantees of the write path. + + Caches written by older versions predate the launcher rejection and the + include-union shape; serving one would bypass both. The dict check also + keeps "in" from substring-matching a bare JSON string. + """ + if not isinstance(cached, dict) or "cc_path" not in cached: + return False + cxx_path = cached.get("cxx_path") + if not isinstance(cxx_path, str) or _is_launcher(cxx_path): + return False + includes = cached.get("includes") + return isinstance(includes, dict) and isinstance(includes.get("build"), list) + + +def load_or_build_idedata( + compile_commands: Path, + elf_path: Path, + cache: Path, + launcher: str | None = None, +) -> dict | None: + """Return idedata for a compile_commands.json build, cached on mtime. + + Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None + when the compile DB doesn't exist yet (nothing was built). ``launcher`` + is the compiler-launcher path (ccache) the build was generated with, if + any; commands in the compile DB are prefixed with it. + """ + if not compile_commands.is_file(): + _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) + return None + + if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: + try: + cached = json.loads(cache.read_text(encoding="utf-8")) + except (ValueError, OSError) as err: + # A recurring cause (interrupted write, disk full) would otherwise + # look like unexplained slow builds + _LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err) + else: + if _cache_usable(cached): + # Re-stamp so a relocated build dir cannot serve a stale ELF path + cached["prog_path"] = str(elf_path) + return cached + _LOGGER.debug("Regenerating idedata: cache %s fails validation", cache) + + data = idedata_from_build(compile_commands, launcher) + data["prog_path"] = str(elf_path) + cache.parent.mkdir(parents=True, exist_ok=True) + # Atomic so a crash mid-write cannot leave a truncated cache + write_file(cache, json.dumps(data, indent=2) + "\n") + return data + + +def reject_launcher_compiler(cxx_path: str) -> None: + """Reject a compile DB naming a launcher (ccache) as the compiler; it + must never be probed, cached, or consumed.""" + if _is_launcher(cxx_path): + raise EsphomeError( + f"compile_commands.json names the launcher {cxx_path} as the " + "compiler; the compile database is unusable" + ) + + +def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict: + """Parse compile_commands.json into the idedata fields consumers expect. + + A single compile entry only carries the include set its own translation + unit was built with (per-component under ESP-IDF), but consumers + (clang-tidy) analyze ESPHome headers that transitively pull in other + components. So take cxx_path / cxx_flags / defines from a representative + ESPHome TU, but union the include dirs across all ESPHome TUs to get a + project-wide superset (as PlatformIO's idedata provides). + """ + entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) + if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries): + # A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS + raise EsphomeError(f"{compile_commands} is not a compile-command list") + + representative = _pick_entry(entries) + cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher) + + # Seed with the representative's includes so it is not parsed twice + has_esphome_tu = _is_esphome_src(representative["file"]) + build_includes: dict[str, None] = dict.fromkeys( + rep_includes if has_esphome_tu else () + ) + + def _shape(entry: dict) -> str: + # directory + command minus TU-specific paths: same shape means the + # same include set, so tokenize once per shape. Response-file + # commands never dedupe (the .rsp contents differ per object) + command = entry["command"] + directory = entry.get("directory", "") + if "@" in command: + return f"unique:{directory}|{entry.get('output') or command}" + stripped = command.replace(entry.get("file", ""), "").replace( + entry.get("output", ""), "" + ) + return f"{directory}|{stripped}" + + seen_shapes = {_shape(representative)} + for entry in entries: + if entry is representative or not _is_esphome_src(entry["file"]): + continue + has_esphome_tu = True + if (shape := _shape(entry)) in seen_shapes: + _LOGGER.debug("Include union: %s shares a command shape", entry["file"]) + continue + seen_shapes.add(shape) + for inc in parse_entry(entry, launcher)[2]: + build_includes.setdefault(inc, None) + + if not has_esphome_tu: + # An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a + # warning would be cached into permanence; call sites downgrade this + raise EsphomeError( + f"No ESPHome translation unit found in {compile_commands}; " + "refusing to cache unusable idedata" + ) + + return { + "cc_path": _cc_path_from_cxx(cxx_path), + "cxx_path": cxx_path, + "cxx_flags": cxx_flags, + "defines": defines, + "includes": { + "build": list(build_includes), + "toolchain": get_toolchain_includes(cxx_path), + }, + } diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py new file mode 100644 index 0000000000..8c25bc9513 --- /dev/null +++ b/esphome/build_helpers/ninja.py @@ -0,0 +1,92 @@ +"""Platform-neutral helpers for ninja-driven native builds.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import re +import shutil + +from esphome.core import EsphomeError +from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs + +_LOGGER = logging.getLogger(__name__) + + +def _ninja_runs(binary: str) -> bool: + """Whether the ninja found on PATH actually runs (see tool_version_runs).""" + return tool_version_runs( + binary, + "Ignoring ninja at %s because it failed to run; " + "falling back to the bundled wheel", + ) + + +def find_ninja() -> Path: + """Locate the ninja binary: a runnable PATH hit first, else the ninja + PyPI wheel.""" + if binary := shutil.which("ninja"): + binary = strip_win_long_path_prefix(binary) + if _ninja_runs(binary): + return Path(binary) + import_error: ImportError | None = None + try: + import ninja + except ImportError as err: + import_error = err + wheel_binary = None + else: + wheel_binary = Path(ninja.BIN_DIR) / ( + "ninja.exe" if os.name == "nt" else "ninja" + ) + if wheel_binary is None or not wheel_binary.is_file(): + raise EsphomeError( + "ninja not found on PATH or in the ninja package; reinstall the " + "esphome Python environment" + ) from import_error + return wheel_binary + + +def escape(value: Path | str) -> str: + """Escape a path or token for a ninja file.""" + return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + + +def quote_arg(tok: str) -> str: + """Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``): + backslash runs double only before a quote. Windows-only; ``$`` must + already be doubled for ninja. + """ + quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok) + quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted) + return f'"{quoted}"' + + +# Force-quote any token containing a character outside the shlex.quote-style +# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, ` +# and friends would be re-parsed as shell syntax. +_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]") + + +def shell_token(tok: str, force: bool = False) -> str: + """Re-quote a lexed token for the platform shell; ``force`` always quotes. + + Single quotes on POSIX (/bin/sh), the argv rule on Windows + (CreateProcess). ``$`` is doubled first because ninja expands it before + the command reaches the shell. + """ + tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing + if not (force or not tok or _NEEDS_QUOTE.search(tok)): + return tok + # An empty token must become '' / "" or it vanishes from the argv + if os.name == "nt": + return quote_arg(tok) + # shlex.quote's rule; inlined because the $-doubled token must not be + # re-examined for safe characters + return "'" + tok.replace("'", "'\"'\"'") + "'" + + +def quote_path(value: Path | str) -> str: + """Force-quote a path for the ninja command line (shell/CreateProcess).""" + return shell_token(str(value), force=True) diff --git a/esphome/build_helpers/size_summary.py b/esphome/build_helpers/size_summary.py new file mode 100644 index 0000000000..b888111044 --- /dev/null +++ b/esphome/build_helpers/size_summary.py @@ -0,0 +1,24 @@ +"""The PlatformIO-format size bar shared by the native toolchains.""" + +from __future__ import annotations + + +def format_bar(used: int, total: int) -> str: + """Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly.""" + pct_raw = used / total if total else 0 + blocks = 10 + filled = min(int(round(blocks * pct_raw)), blocks) + progress = "=" * filled + return ( + f"[{progress:<{blocks}}] {pct_raw: 6.1%} " + f"(used {used:d} bytes from {total:d} bytes)" + ) + + +def print_size_line(label: str, used: int, total: int) -> None: + """One PlatformIO-format summary line (``RAM``/``Flash``). + + The label padding is part of the format: ``script/ci_memory_impact_extract.py`` + matches these lines verbatim. + """ + print(f"{label + ':':<7}{format_bar(used, total)}") diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py new file mode 100644 index 0000000000..e7193a8e2a --- /dev/null +++ b/esphome/build_helpers/tools_cache.py @@ -0,0 +1,36 @@ +"""Machine-global tools cache location shared by the native backends.""" + +from __future__ import annotations + +from pathlib import Path + + +def tools_cache_path(env_var: str, subdir: str) -> Path: + """A backend's machine-global tools directory, with an env override. + + A blank/whitespace override is treated as unset: ``Path("")`` resolves + to the CWD, which ``clean-all`` would then delete. + """ + import platformdirs + + from esphome.helpers import get_str_env + + if prefix := get_str_env(env_var, "").strip(): + # resolve(): symlinked prefixes otherwise trip idf.py's + # venv-mismatch warning on every build + return Path(prefix).expanduser().resolve() + # appauthor=False keeps the Windows path short (no vendor segment); + # deep IDF trees run into MAX_PATH otherwise + return ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir + ).resolve() + + +# (env override, cache subdir) per native backend. writer.clean_all wipes +# every entry via tools_cache_path, so listing a cache here is the single +# step that registers it for removal; the backends' own path getters use +# the same named pairs so the two cannot drift. +IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf") +SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf") +ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266") +TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE) diff --git a/esphome/bundle.py b/esphome/bundle.py index 70c4fad0fd..b633c5ca4f 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz`` from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum import io import json import logging -from pathlib import Path +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath import re import shutil import tarfile @@ -20,6 +20,7 @@ from typing import Any from esphome import const, yaml_util from esphome.const import ( + BUNDLE_EXTENSION, CONF_ESPHOME, CONF_EXTERNAL_COMPONENTS, CONF_INCLUDES, @@ -29,10 +30,12 @@ from esphome.const import ( CONF_TYPE, ) from esphome.core import CORE, EsphomeError +from esphome.util import filter_yaml_files _LOGGER = logging.getLogger(__name__) -BUNDLE_EXTENSION = ".esphomebundle.tar.gz" +DOMAIN = "bundle" + MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB @@ -49,6 +52,7 @@ class ManifestKey(StrEnum): MANIFEST_VERSION = "manifest_version" ESPHOME_VERSION = "esphome_version" CONFIG_FILENAME = "config_filename" + CONFIG_DIR = "config_dir" FILES = "files" HAS_SECRETS = "has_secrets" @@ -120,6 +124,153 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: return keys +@dataclass +class BundleData: + """Files components asked to include, keyed under DOMAIN in CORE.data.""" + + extra_files: list[Path] = field(default_factory=list) + # Directories whose YAML files are scanned for !secret references but + # never bundled, e.g. git package checkouts the builder re-fetches. + secret_scan_dirs: set[Path] = field(default_factory=set) + # Original config dir parsed from an extracted bundle's manifest.json, + # kept in the path flavor of the machine the bundle was created on. + # The checked flag makes the manifest lookup happen at most once per run; + # CORE.data is cleared between runs. + original_config_dir: PurePath | None = None + original_config_dir_checked: bool = False + + +def _get_data() -> BundleData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = BundleData() + return CORE.data[DOMAIN] + + +def add_bundle_file(path: Path) -> None: + """Register a file that a bundle must include. + + Bundle discovery walks the validated config, so it only finds files the config + names. Components call this during validation for files it cannot see, such as a + file that is referenced from inside another file. + + A relative path is taken as relative to the config directory. Files outside the + config directory are skipped when the bundle is built. + """ + _get_data().extra_files.append(CORE.relative_config_path(path)) + + +def add_secret_scan_dir(path: Path) -> None: + """Register a directory to scan for ``!secret`` references when bundling. + + The directory's files are not added to the bundle. Components call this + for YAML the build consumes without bundling it — such as git-fetched + packages, which the builder re-fetches — so the secrets those files + reference are still shipped in the filtered secrets file. + + A relative path is taken as relative to the config directory. + """ + if not path.is_absolute(): + path = CORE.relative_config_path(path) + _get_data().secret_scan_dirs.add(path) + + +def _secret_scan_yaml_files() -> list[Path]: + """Return the YAML files inside registered secret-scan directories.""" + return filter_yaml_files( + f + for scan_dir in _get_data().secret_scan_dirs + for f in yaml_util.find_files(scan_dir, "*") + ) + + +# Windows paths start with a drive letter or contain backslashes; POSIX +# paths do neither in practice, so this is how the flavor of a recorded +# path string is recognized on any host. +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:") + + +def _path_flavor(value: str) -> type[PurePath]: + """Pick the pure path class matching the flavor ``value`` was written in.""" + if "\\" in value or _WINDOWS_DRIVE_RE.match(value): + return PureWindowsPath + return PurePosixPath + + +def _load_original_config_dir() -> PurePath | None: + """Read the original config dir from an extracted bundle's manifest. + + Returns None when the current config dir is not an extracted bundle or + the manifest does not record the original config dir. + """ + manifest_path = CORE.config_dir / MANIFEST_FILENAME + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except FileNotFoundError: + # The common case: this config dir is not an extracted bundle. + return None + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err: + # A manifest.json is present but unreadable or malformed. Say so + # instead of letting it look identical to "not a bundle". + _LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err) + return None + if not isinstance(manifest, dict): + return None + # A manifest.json in the config dir does not have to be ours. Only trust + # one that looks like a bundle manifest for exactly this config file. + version = manifest.get(ManifestKey.MANIFEST_VERSION) + if not isinstance(version, int) or version < 1: + return None + if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name: + return None + config_dir = manifest.get(ManifestKey.CONFIG_DIR) + if not isinstance(config_dir, str) or not config_dir: + return None + return _path_flavor(config_dir)(config_dir) + + +def remap_bundle_path(value: str) -> Path | None: + """Remap an absolute path from the machine a bundle was created on. + + A bundled config may reference files by absolute path. The referenced + files ship inside the bundle at their config-relative locations, but the + YAML text is copied verbatim, so after extraction on another machine the + absolute reference points at a path that only existed on the creating + machine. The bundle manifest records that machine's config dir; when + ``value`` names a path that lived under it, return the corresponding + file next to the extracted config. + + ``value`` is the raw path string from the config. It is parsed with the + original machine's path flavor, so a bundle created on Windows remaps on + a POSIX build server and vice versa. + + Returns None when not compiling an extracted bundle, when ``value`` was + not under the original config dir, or when the bundle does not contain + the file. + """ + data = _get_data() + if not data.original_config_dir_checked: + data.original_config_dir_checked = True + data.original_config_dir = _load_original_config_dir() + original_dir = data.original_config_dir + if original_dir is None: + return None + path = type(original_dir)(value) + if not path.is_absolute(): + return None + try: + rel = path.relative_to(original_dir) + except ValueError: + return None + # relative_to is lexical, so ".." segments survive it. Refuse them: the + # remapped file must land strictly inside the extracted config tree. + if ".." in rel.parts: + return None + remapped = CORE.relative_config_path(Path(*rel.parts)) + if not remapped.exists(): + return None + return remapped + + @dataclass class BundleFile: """A file to include in the bundle.""" @@ -146,6 +297,7 @@ class BundleManifest: config_filename: str files: list[str] has_secrets: bool + config_dir: str | None = None class ConfigBundleCreator: @@ -186,6 +338,7 @@ class ConfigBundleCreator: yaml_sources = [ bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") ] + yaml_sources.extend(_secret_scan_yaml_files()) used_secret_keys = _find_used_secret_keys(yaml_sources) filtered_secrets = self._build_filtered_secrets(used_secret_keys) @@ -260,42 +413,27 @@ class ConfigBundleCreator: def _discover_yaml_includes(self) -> None: """Discover YAML files loaded during config parsing. - Deliberately uses a fresh re-parse and force-loads every deferred - ``IncludeFile`` to include *all* potentially-reachable includes, - even branches not selected by the local substitutions. Bundles are - meant to be compiled on another system where command-line - substitution overrides may choose a different branch — e.g. - ``!include network/${eth_model}/config.yaml`` must ship every - candidate so the remote build can pick any one. - - Entries with unresolved substitution variables in the filename - path are skipped with a warning (they cannot be resolved without - the substitution pass). - - Secrets files are tracked separately so we can filter them to - only include the keys this config actually references. + Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a + fresh re-parse and force-loads every deferred ``IncludeFile`` so that + *all* potentially-reachable includes are captured (even branches not + selected by local substitutions). Bundles are meant to be compiled on + another system where command-line substitution overrides may choose a + different branch — e.g. ``!include network/${eth_model}/config.yaml`` + must ship every candidate so the remote build can pick any one. """ - # Must be a fresh parse: IncludeFile.load() caches its result in - # _content, and we discover files by listening for loader calls. On - # an already-parsed tree the cache is populated, .load() returns - # without calling the loader, the listener never fires, and the - # referenced files would be silently dropped from the bundle. - with yaml_util.track_yaml_loads() as loaded_files: - try: - data = yaml_util.load_yaml(self._config_path) - except EsphomeError: - _LOGGER.debug( - "Bundle: re-loading YAML for include discovery failed, " - "proceeding with partial file list" - ) - else: - _force_load_include_files(data) - - for fpath in loaded_files: - if fpath == self._config_path.resolve(): + discovered = yaml_util.discover_user_yaml_files(self._config_path) + self._secrets_paths.update(discovered.secrets) + # A !secret inside a file this re-parse does not reach (for example + # a git-fetched package the builder re-fetches) still resolves + # against the config-dir secrets.yaml at build time, so always + # consider that file; filtering no-ops when no key matches. + default_secrets = self._config_dir / yaml_util.SECRET_YAML + if default_secrets.is_file(): + self._secrets_paths.add(default_secrets.resolve()) + config_resolved = self._config_path.resolve() + for fpath in discovered.files: + if fpath == config_resolved: continue # Already added as config - if fpath.name in const.SECRETS_FILES: - self._secrets_paths.add(fpath) self._add_file(fpath) def _discover_component_files(self) -> None: @@ -308,13 +446,18 @@ class ConfigBundleCreator: with known file extensions are also resolved and checked. Core ESPHome concepts that use relative paths or directories - are handled explicitly. + are handled explicitly. Files the config does not name at all are + registered by their component with add_bundle_file(). """ config = self._config # Generic walk: find all file paths in the validated config self._walk_config_for_files(config) + # Files registered by components during validation + for extra_file in _get_data().extra_files: + self._add_file(extra_file) + # --- Core ESPHome concepts needing explicit handling --- # esphome.includes / includes_c - can be relative paths and directories @@ -427,6 +570,7 @@ class ConfigBundleCreator: ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION, ManifestKey.ESPHOME_VERSION: const.__version__, ManifestKey.CONFIG_FILENAME: self._config_path.name, + ManifestKey.CONFIG_DIR: str(self._config_dir), ManifestKey.FILES: [f.path for f in files], ManifestKey.HAS_SECRETS: has_secrets, } @@ -434,7 +578,7 @@ class ConfigBundleCreator: @staticmethod def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None: """Add a BundleFile to the tar archive with deterministic metadata.""" - with open(bf.source, "rb") as f: + with bf.source.open("rb") as f: _add_bytes_to_tar(tar, bf.path, f.read()) @@ -511,12 +655,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest: except tarfile.TarError as err: raise EsphomeError(f"Failed to read bundle: {err}") from err + config_dir = manifest.get(ManifestKey.CONFIG_DIR) return BundleManifest( manifest_version=manifest[ManifestKey.MANIFEST_VERSION], esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"), config_filename=manifest[ManifestKey.CONFIG_FILENAME], files=manifest.get(ManifestKey.FILES, []), has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False), + config_dir=config_dir if isinstance(config_dir, str) else None, ) @@ -609,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None: ) -def is_bundle_path(path: Path) -> bool: - """Check if a path looks like a bundle file.""" - return path.name.lower().endswith(BUNDLE_EXTENSION) - - def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: """Add in-memory bytes to a tar archive with deterministic metadata.""" info = tarfile.TarInfo(name=name) @@ -625,57 +766,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: tar.addfile(info, io.BytesIO(data)) -def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None: - """Recursively resolve any ``IncludeFile`` instances in a YAML tree. - - Nested ``!include`` returns a deferred ``IncludeFile`` that is only - resolved during the substitution pass. During bundle discovery we need - the referenced files to actually load so the ``track_yaml_loads`` - listener fires for them. - - ``IncludeFile`` instances with unresolved substitution variables in the - filename cannot be loaded — we skip and warn about those. - """ - if _seen is None: - _seen = set() - - if isinstance(obj, yaml_util.IncludeFile): - if id(obj) in _seen: - return - _seen.add(id(obj)) - if obj.has_unresolved_expressions(): - _LOGGER.warning( - "Bundle: cannot resolve !include %s (referenced from %s) " - "with substitutions in path", - obj.file, - obj.parent_file, - ) - return - try: - loaded = obj.load() - except EsphomeError as err: - _LOGGER.warning( - "Bundle: failed to load !include %s (referenced from %s): %s", - obj.file, - obj.parent_file, - err, - ) - return - _force_load_include_files(loaded, _seen) - elif isinstance(obj, dict): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for value in obj.values(): - _force_load_include_files(value, _seen) - elif isinstance(obj, (list, tuple)): - if id(obj) in _seen: - return - _seen.add(id(obj)) - for item in obj: - _force_load_include_files(item, _seen) - - def _resolve_include_path(include_path: Any) -> Path | None: """Resolve an include path to absolute, skipping system includes.""" if isinstance(include_path, str) and include_path.startswith("<"): diff --git a/esphome/codegen.py b/esphome/codegen.py index a5b5abe447..2aa6a70abd 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,8 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cmake_arg, + add_cxx_build_flag, add_define, add_global, add_library, @@ -48,10 +50,13 @@ from esphome.cpp_helpers import ( # noqa: F401 build_registry_entry, build_registry_list, extract_registry_entry_config, + get_slot_count, gpio_pin_expression, past_safe_mode, register_component, register_parented, + set_setup_priority, + slot_counter, ) from esphome.cpp_types import ( # noqa: F401 NAN, diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py new file mode 100644 index 0000000000..0d855d71db --- /dev/null +++ b/esphome/compiled_config.py @@ -0,0 +1,219 @@ +"""Validated-config cache for the upload/logs fast path. + +compile dumps the validated config to /storage/.validated.json; +the next upload/logs for that YAML reuses it instead of running the full +read_config pipeline. The cache is deliberately lossy: only ``!lambda`` +bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses, +paths, UUIDs and enums store the same string form the YAML dumper +produced for them. JSON additionally coerces non-str dict keys to +strings; validated configs only use string keys (every schema key +validator is ``cv.string``). mtime gates staleness. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from esphome.const import __version__ as ESPHOME_VERSION +from esphome.core import CORE, EsphomeError, Lambda +from esphome.helpers import write_file +from esphome.storage_json import StorageJSON, ext_storage_path, storage_path +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +# Bump when the on-disk shape changes; a mismatched version falls back +# to read_config. The envelope also stamps the writing esphome version: +# after an upgrade the cache holds the previous release's validation, so +# it falls back once and the re-save self-heals. +_CACHE_VERSION = 1 +_LAMBDA_KEY = "__esphome_lambda__" + + +def compiled_config_path(config_filename: str) -> Path: + """Path to the cached validated config alongside the storage sidecar.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.json" + + +def save_compiled_config(config: ConfigType) -> None: + """Write the validated-config cache. Always-write so mtime stays fresh. + + Mode 0600 because config validation resolved !secret inline. + Failures are non-fatal: the fast path falls back to read_config. + """ + try: + # The legacy YAML cache holds inline-resolved secrets and nothing + # reads it anymore; drop it even when the write below fails. A + # failed removal leaves resolved secrets on disk, so it warns. + try: + _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True) + except OSError as err: + _LOGGER.warning( + "Could not remove the legacy validated-config cache: %s", err + ) + rendered = json.dumps( + {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config}, + separators=(",", ":"), + default=_json_default, + ) + write_file(compiled_config_path(CORE.config_filename), rendered, private=True) + except TypeError as err: + # Structural, not transient: this config can never cache (e.g. a + # non-basic dict key), so every upload/logs pays the slow path. + _LOGGER.warning("Cannot cache the validated config: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Likely persistent (permissions, full disk): every upload/logs + # pays the slow path until it clears, so surface it. + _LOGGER.warning("Skipping compiled config cache write: %s", err) + + +def save_compiled_config_and_sidecar(config: ConfigType) -> None: + """Refresh the cache from the upload/logs fallback (CORE.config must be set). + + The cache is only written when a complete sidecar is on disk: + load_compiled_config can't use it otherwise, and it holds resolved + secrets. + """ + if _refresh_sidecar(): + save_compiled_config(config) + + +def _refresh_sidecar() -> bool: + """Ensure a complete sidecar is on disk; True when one is. + + Writes one (without claiming a build) when missing or wizard-only. + Failures are non-fatal; the next upload/logs pays the slow path again. + """ + try: + path = storage_path() + try: + old = StorageJSON.load_strict(path) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # Present but unreadable: it may hold a real build's metadata, + # and a fresh rewrite would also stop the next compile from + # cleaning a possibly incoherent build tree. + _LOGGER.warning( + "Not caching: storage sidecar %s is unreadable (%s)", path, err + ) + return False + if old is not None and old.can_apply_to_core(): + if ( + old.toolchain is not None + and CORE.toolchain is not None + and old.toolchain != CORE.toolchain.value + ): + # Platforms normalize toolchain-sensitive keys differently; + # never cache a config validated under a different toolchain + # than the compile's + _LOGGER.debug( + "Not caching: config validated with toolchain %r but the " + "last compile used %r", + CORE.toolchain.value, + old.toolchain, + ) + return False + # Compile-written; nothing to refresh. + return True + if CORE.build_path is not None and CORE.build_path.exists(): + # An unvalidated build tree: its absent or mismatched sidecar + # is what makes the next compile wipe it, so don't vouch for + # a build this run never saw. + _LOGGER.warning( + "Not caching: build tree %s has no matching sidecar; " + "'esphome compile' will settle it", + CORE.build_path, + ) + return False + new = StorageJSON.from_esphome_core(CORE, old, claim_build=False) + if not new.can_apply_to_core(): + _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete") + return False + new.save(path) + return True + except (OSError, EsphomeError) as err: + # write_file wraps OSError into EsphomeError. Persistent + # (unwritable storage dir), so surface that every upload/logs + # pays the slow path. + _LOGGER.warning("Could not refresh the storage sidecar: %s", err) + except Exception: # noqa: BLE001 # pylint: disable=broad-except + # A structural bug; keep the traceback so it isn't mistaken + # for the I/O failure above. + _LOGGER.warning( + "Unexpected error refreshing the storage sidecar", exc_info=True + ) + return False + + +def load_compiled_config(conf_path: Path) -> ConfigType | None: + """Load the cached validated config and apply storage metadata to CORE. + + Returns None (caller falls back to read_config) when the cache is + missing, older than the source YAML, unparseable, a different cache + version, or the sidecar is incomplete. The loaded config carries no + source ranges; callers must not feed it into read_config/write_cpp. + """ + cache_path = compiled_config_path(conf_path.name) + if not _cache_is_fresh(cache_path, conf_path): + return None + + try: + envelope = json.loads( + cache_path.read_text(encoding="utf-8"), object_hook=_decode_object + ) + except (OSError, ValueError) as err: + _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err) + return None + + if ( + not isinstance(envelope, dict) + or envelope.get("v") != _CACHE_VERSION + or envelope.get("esphome") != ESPHOME_VERSION + or not isinstance(config := envelope.get("config"), dict) + ): + _LOGGER.debug("Ignoring compiled config cache with a foreign envelope") + return None + + storage = StorageJSON.load(ext_storage_path(conf_path.name)) + if storage is None or not storage.can_apply_to_core(): + _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete") + return None + storage.apply_to_core() + return config + + +# Remove before 2027.8: by then every maintained install has saved the +# JSON cache at least once and dropped its legacy YAML file. +def _legacy_compiled_config_path(config_filename: str) -> Path: + """Path of the pre-JSON YAML cache; only ever removed.""" + return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" + + +def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: + """True iff the cache file exists and isn't older than the source.""" + try: + return cache_path.stat().st_mtime >= source_path.stat().st_mtime + except OSError: + return False + + +def _json_default(value: Any) -> Any: + """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest + stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums). + + IncludeFile/Extend/Remove have no JSON mirror and would stringify + wrong, but none survive validation (config.py's packages merge and + the substitution pass consume them) so no guard is spent on them. + """ + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) + + +def _decode_object(obj: dict[str, Any]) -> Any: + """Revive the Lambda sentinel; every other mapping passes through.""" + if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str): + return Lambda(value) + return obj diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py new file mode 100644 index 0000000000..e701bd98d4 --- /dev/null +++ b/esphome/component_aliases.py @@ -0,0 +1,10 @@ +"""Component alias registry. + +Generated by script/build_alias_registry.py - do not edit manually. +See the component-alias section of esphome/loader.py. +""" + +# alias -> (canonical component, removal version or None) +COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "rp2040": ("rp2", "2027.7.0"), +} 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/a01nyub/sensor.py b/esphome/components/a01nyub/sensor.py index e5f4f7ef30..f84091d688 100644 --- a/esphome/components/a01nyub/sensor.py +++ b/esphome/components/a01nyub/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType CODEOWNERS = ["@MrSuicideParrot"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) 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/a02yyuw/sensor.py b/esphome/components/a02yyuw/sensor.py index f0bc59ae6c..7372f8f760 100644 --- a/esphome/components/a02yyuw/sensor.py +++ b/esphome/components/a02yyuw/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_MILLIMETER, ) +from esphome.types import ConfigType CODEOWNERS = ["@TH-Braemer"] DEPENDENCIES = ["uart"] @@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) 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/a4988/stepper.py b/esphome/components/a4988/stepper.py index 97f5a6fe0f..7a19bd550d 100644 --- a/esphome/components/a4988/stepper.py +++ b/esphome/components/a4988/stepper.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import stepper import esphome.config_validation as cv from esphome.const import CONF_DIR_PIN, CONF_ID, CONF_SLEEP_PIN, CONF_STEP_PIN +from esphome.types import ConfigType a4988_ns = cg.esphome_ns.namespace("a4988") A4988 = a4988_ns.class_("A4988", stepper.Stepper, cg.Component) @@ -17,7 +18,7 @@ CONFIG_SCHEMA = stepper.STEPPER_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await stepper.register_stepper(var, config) 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/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py index caaa546e25..84a69dfa23 100644 --- a/esphome/components/absolute_humidity/sensor.py +++ b/esphome/components/absolute_humidity/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_GRAMS_PER_CUBIC_METER, ) +from esphome.types import ConfigType absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity") AbsoluteHumidityComponent = absolute_humidity_ns.class_( @@ -43,7 +44,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) 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/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py index 1f35095e0e..498565b0ea 100644 --- a/esphome/components/ac_dimmer/output.py +++ b/esphome/components/ac_dimmer/output.py @@ -4,6 +4,7 @@ from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_METHOD, CONF_MIN_POWER from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] @@ -48,7 +49,13 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the gptimer driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_gptimer") + if CORE.is_esp8266: # ac_dimmer uses setTimer1Callback which requires the waveform generator from esphome.components.esp8266.const import require_waveform diff --git a/esphome/components/adalight/__init__.py b/esphome/components/adalight/__init__.py index 5e122676cd..afdfefaba6 100644 --- a/esphome/components/adalight/__init__.py +++ b/esphome/components/adalight/__init__.py @@ -4,6 +4,9 @@ from esphome.components.light.effects import register_addressable_effect from esphome.components.light.types import AddressableLightEffect import esphome.config_validation as cv from esphome.const import CONF_NAME, CONF_UART_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -21,7 +24,7 @@ CONFIG_SCHEMA = cv.Schema({}) "Adalight", {cv.GenerateID(CONF_UART_ID): cv.use_id(uart.UARTComponent)}, ) -async def adalight_light_effect_to_code(config, effect_id): +async def adalight_light_effect_to_code(config: ConfigType, effect_id: ID) -> MockObj: effect = cg.new_Pvariable(effect_id, config[CONF_NAME]) await uart.register_uart_device(effect, config) return effect diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 96c8334a6d..5c763a4f4c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( @@ -16,6 +18,7 @@ from esphome.components.esp32 import ( import esphome.config_validation as cv from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -225,14 +228,15 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { } -def validate_adc_pin(value): +def validate_adc_pin(value: Any) -> ConfigType | str: 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") + # Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0 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 +265,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_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 16c86aee18..5ca58df10e 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.common"; +static const char *const TAG = "adc"; const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index a761b37749..a0f7a1ed08 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.esp32"; +static const char *const TAG = "adc"; adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index e4f2f82f08..77a192e025 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc.esp8266"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index d9b9f50be1..dfa545b395 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.libretiny"; +static const char *const TAG = "adc"; void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2.cpp similarity index 60% rename from esphome/components/adc/adc_sensor_rp2040.cpp rename to esphome/components/adc/adc_sensor_rp2.cpp index 8d41edb814..ce665e8501 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,26 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2040"; +static const char *const TAG = "adc"; + +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif void ADCSensor::setup() { static bool initialized = false; @@ -52,7 +71,7 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - adc_select_input(4); + adc_select_input(TEMPERATURE_ADC_INPUT); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); @@ -66,15 +85,18 @@ float ADCSensor::sample() { } uint8_t pin = this->pin_->get_pin(); -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { // Measuring VSYS on Raspberry Pico W needs to be wrapped with // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and - // VSYS ADC both share GPIO29 + // VSYS ADC both share GPIO29. + // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined + // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43 + // driver is never initialized; calling cyw43_thread_enter() there hard-faults. cyw43_thread_enter(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) adc_gpio_init(pin); adc_select_input(pin - 26); @@ -84,11 +106,11 @@ float ADCSensor::sample() { aggr.add_sample(raw); } -#ifdef CYW43_USES_VSYS_PIN +#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (pin == PICO_VSYS_PIN) { cyw43_thread_exit(); } -#endif // CYW43_USES_VSYS_PIN +#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) if (this->output_raw_) { return aggr.aggregate(); @@ -99,4 +121,4 @@ float ADCSensor::sample() { } // namespace esphome::adc -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c3632b00e2..bf45059740 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc.zephyr"; +static const char *const TAG = "adc"; void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 09e09f0dc1..5d1031825e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -10,8 +10,8 @@ from esphome.components.esp32 import ( from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.zephyr import ( zephyr_add_overlay, + zephyr_add_overlay_builder, zephyr_add_prj_conf, - zephyr_add_user, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto": raise cv.Invalid("Automatic attenuation cannot be used when raw output is set") @@ -67,6 +67,13 @@ def validate_config(config): # Alter value here so `config` command prints the recommended change config[CONF_ATTENUATION] = _attenuation("12db") + # Remove before 2027.2.0 + if config[CONF_PIN] == "TEMPERATURE": + _LOGGER.warning( + "[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` " + "sensor platform instead. Will be removed in 2027.2.0" + ) + return config @@ -113,7 +120,19 @@ CONFIG_SCHEMA = cv.All( CONF_ADC_CHANNEL_ID = "adc_channel_id" -async def to_code(config): +def _overlay_io_channels() -> str: + channel_count = CORE.data[CONF_ADC_CHANNEL_ID] + entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count)) + return f""" + / {{ + zephyr,user {{ + io-channels = {entries}; + }}; + }}; + """ + + +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) @@ -121,6 +140,7 @@ async def to_code(config): if config[CONF_PIN] == "VCC": cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": + # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) @@ -173,9 +193,8 @@ async def to_code(config): if isinstance(pin_number, int): GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} pin_number = GPIO_TO_AIN[pin_number] - zephyr_add_user("io-channels", f"<&adc {channel_id}>") - zephyr_add_overlay( - f""" + zephyr_add_overlay_builder(_overlay_io_channels) + zephyr_add_overlay(f""" &adc {{ #address-cells = <1>; #size-cells = <0>; @@ -190,8 +209,7 @@ async def to_code(config): zephyr,oversampling = <8>; }}; }}; - """ - ) + """) FILTER_SOURCE_FILES = filter_source_files_from_platform( @@ -201,7 +219,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/__init__.py b/esphome/components/adc128s102/__init__.py index a5281aacc7..684147752d 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["spi"] MULTI_CONF = True @@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(spi.spi_device_schema(cs_pin_required=True)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/adc128s102/sensor/__init__.py index a65ae9d537..04589a7ce2 100644 --- a/esphome/components/adc128s102/sensor/__init__.py +++ b/esphome/components/adc128s102/sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import sensor, voltage_sampler import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType from .. import ADC128S102, adc128s102_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_CHANNEL], 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/addressable_light/display.py b/esphome/components/addressable_light/display.py index 929d45121c..1db01b40f9 100644 --- a/esphome/components/addressable_light/display.py +++ b/esphome/components/addressable_light/display.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) +from esphome.types import ConfigType CODEOWNERS = ["@justfalter"] @@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) wrapped_light = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID]) cg.add(var.set_width(config[CONF_WIDTH])) diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py index aed63c7dfa..e69de29bb2 100644 --- a/esphome/components/ade7880/__init__.py +++ b/esphome/components/ade7880/__init__.py @@ -1 +0,0 @@ -CODEOWNERS = ["@kpfleming"] diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 9d19770c57..0f4189ad90 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -87,14 +87,24 @@ void ADE7880::update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_ sensor->publish_state(f(val)); } -template -void ADE7880::update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f) { - if (sensor == nullptr) { +void ADE7880::update_active_energy_(PowerChannel *channel, uint16_t a_register) { + if (channel->forward_active_energy == nullptr && channel->reverse_active_energy == nullptr) { return; } - float val = this->read_s32_register16_(a_register); - sensor->publish_state(f(val)); + // The ADE7880 has no separate forward/reverse active energy accumulators. The xWATTHR registers + // accumulate signed energy since the last read (positive = imported/forward, negative = exported/ + // reverse), so split the value by sign into the forward and reverse running totals. + float val = this->read_s32_register16_(a_register) / 14400.0f; + if (val >= 0.0f) { + if (channel->forward_active_energy != nullptr) { + channel->forward_active_energy->publish_state(channel->forward_active_energy_total += val); + } + } else { + if (channel->reverse_active_energy != nullptr) { + channel->reverse_active_energy->publish_state(channel->reverse_active_energy_total -= val); + } + } } void ADE7880::update() { @@ -117,12 +127,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, APF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, AWATTHR); } if (this->channel_b_ != nullptr) { @@ -133,12 +138,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, BVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, BPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, BWATTHR); } if (this->channel_c_ != nullptr) { @@ -149,12 +149,7 @@ void ADE7880::update() { this->update_sensor_from_s24zp_register16_(chan->apparent_power, CVA, [](float val) { return val / 100.0f; }); this->update_sensor_from_s16_register16_(chan->power_factor, CPF, [](float val) { return std::abs(val / -327.68f); }); - this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) { - return chan->forward_active_energy_total += val / 14400.0f; - }); - this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) { - return chan->reverse_active_energy_total += val / 14400.0f; - }); + this->update_active_energy_(chan, CWATTHR); } ESP_LOGD(TAG, "update took %" PRIu32 " ms", millis() - start); diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h index 69c8e5abba..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; } @@ -105,7 +105,8 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent { // the callable will be passed a 'float' value and is expected to return a 'float' template void update_sensor_from_s24zp_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); template void update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); - template void update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f); + + void update_active_energy_(PowerChannel *channel, uint16_t a_register); void reset_device_(); diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h index aee4e42445..8b0b86fe7a 100644 --- a/esphome/components/ade7880/ade7880_registers.h +++ b/esphome/components/ade7880/ade7880_registers.h @@ -84,9 +84,7 @@ constexpr uint16_t CWATTHR = 0xE402; constexpr uint16_t AFWATTHR = 0xE403; constexpr uint16_t BFWATTHR = 0xE404; constexpr uint16_t CFWATTHR = 0xE405; -constexpr uint16_t ARWATTHR = 0xE406; -constexpr uint16_t BRWATTHR = 0xE407; -constexpr uint16_t CRWATTHR = 0xE408; +// 0xE406-0xE408 are reserved on the ADE7880 (it does not implement total reactive energy accumulation) constexpr uint16_t AFVARHR = 0xE409; constexpr uint16_t BFVARHR = 0xE40A; constexpr uint16_t CFVARHR = 0xE40B; diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index beb74d7310..93c279e235 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -36,6 +36,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.cpp_generator import MockObj from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -243,7 +244,7 @@ CONFIG_SCHEMA = cv.All( ) -async def neutral_channel(config): +async def neutral_channel(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) current = config[CONF_CURRENT] @@ -257,7 +258,7 @@ async def neutral_channel(config): return var -async def power_channel(config): +async def power_channel(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) for sensor_type in POWER_SENSOR_TYPES: @@ -280,7 +281,7 @@ async def power_channel(config): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 4fc35352f9..71250ac94e 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -23,6 +23,8 @@ from esphome.const import ( UNIT_VOLT_AMPS_REACTIVE, UNIT_WATT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@angelnu"] @@ -163,7 +165,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def register_ade7953(var, config): +async def register_ade7953(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) if irq_pin_config := config.get(CONF_IRQ_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/ade7953_i2c/sensor.py b/esphome/components/ade7953_i2c/sensor.py index 4b55acdafa..8447042d30 100644 --- a/esphome/components/ade7953_i2c/sensor.py +++ b/esphome/components/ade7953_i2c/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ade7953_base, i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["ade7953_base"] @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await i2c.register_i2c_device(var, config) await ade7953_base.register_ade7953(var, config) diff --git a/esphome/components/ade7953_spi/sensor.py b/esphome/components/ade7953_spi/sensor.py index dce021daad..6fdf2147f3 100644 --- a/esphome/components/ade7953_spi/sensor.py +++ b/esphome/components/ade7953_spi/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ade7953_base, spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["spi"] AUTO_LOAD = ["ade7953_base"] @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config) await ade7953_base.register_ade7953(var, config) diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py index 6d52fc83fd..b42ee918c5 100644 --- a/esphome/components/ads1115/__init__.py +++ b/esphome/components/ads1115/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/ads1115/sensor/__init__.py index afb70d07c8..742f82d302 100644 --- a/esphome/components/ads1115/sensor/__init__.py +++ b/esphome/components/ads1115/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import CONF_ADS1115_ID, ADS1115Component, ads1115_ns @@ -86,7 +87,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await sensor.register_sensor(var, config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/ads1118/__init__.py index 45d47a329e..956b9a0c1f 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] DEPENDENCIES = ["spi"] @@ -23,7 +24,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/ads1118/sensor/__init__.py index 33bfe97789..6bc3baa2e4 100644 --- a/esphome/components/ads1118/sensor/__init__.py +++ b/esphome/components/ads1118/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import ADS1118, CONF_ADS1118_ID, ads1118_ns @@ -86,7 +87,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_ADS1118_ID]) 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/ags10/sensor.py b/esphome/components/ags10/sensor.py index 6491d7d810..8606e7c247 100644 --- a/esphome/components/ags10/sensor.py +++ b/esphome/components/ags10/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_OHM, UNIT_PARTS_PER_BILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_RESISTANCE = "resistance" @@ -62,7 +65,7 @@ CONFIG_SCHEMA = ( FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz") -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( AGS10_NEW_I2C_ADDRESS_SCHEMA, synchronous=True, ) -async def ags10newi2caddress_to_code(config, action_id, template_arg, args): +async def ags10newi2caddress_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) @@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema( AGS10_SET_ZERO_POINT_SCHEMA, synchronous=True, ) -async def ags10setzeropoint_to_code(config, action_id, template_arg, args): +async def ags10setzeropoint_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) mode = await cg.templatable( 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/aht10/sensor.py b/esphome/components/aht10/sensor.py index a5b1cf0ffb..ae669d0000 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +51,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b478b573a3..50e2f81f1b 100644 --- a/esphome/components/aic3204/audio_dac.py +++ b/esphome/components/aic3204/audio_dac.py @@ -4,6 +4,9 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value( SET_AUTO_MUTE_ACTION_SCHEMA, synchronous=True, ) -async def aic3204_set_volume_to_code(config, action_id, template_arg, args): +async def aic3204_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/airthings_ble/__init__.py index 1545110798..44534b80e9 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -1,23 +1,27 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeromelaban"] airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") AirthingsListener = airthings_ble_ns.class_( - "AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener + "AirthingsListener", ble_device_base.ESPBTDeviceListener ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(AirthingsListener), - } -).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("airthings_ble"), + cv.Schema( + { + cv.GenerateID(): cv.declare_id(AirthingsListener), + } + ).extend(ble_device_base.BLE_DEVICE_SCHEMA), +) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index 881b3e297b..f2625a7832 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -2,15 +2,13 @@ #include "esphome/core/log.h" #include -#ifdef USE_ESP32 - namespace esphome::airthings_ble { static const char *const TAG = "airthings_ble"; -bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) { for (auto &it : device.get_manufacturer_datas()) { - if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) { + if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) { if (it.data.size() < 4) continue; @@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h index 707e9c3f21..8fdfeb972f 100644 --- a/esphome/components/airthings_ble/airthings_listener.h +++ b/esphome/components/airthings_ble/airthings_listener.h @@ -1,17 +1,13 @@ #pragma once -#ifdef USE_ESP32 - #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::airthings_ble { -class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener { +class AirthingsListener final : public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::airthings_ble - -#endif diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index c3f3b8f199..58fde11a3d 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -20,8 +20,10 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType -CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"] +CODEOWNERS = ["@ncareau", "@jeromelaban"] DEPENDENCIES = ["ble_client"] @@ -78,7 +80,7 @@ BASE_SCHEMA = ( ) -async def wave_base_to_code(var, config): +async def wave_base_to_code(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await ble_client.register_ble_node(var, config) 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_mini/sensor.py b/esphome/components/airthings_wave_mini/sensor.py index f231be6670..9136b333e2 100644 --- a/esphome/components/airthings_wave_mini/sensor.py +++ b/esphome/components/airthings_wave_mini/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import airthings_wave_base import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = airthings_wave_base.DEPENDENCIES @@ -20,6 +21,6 @@ CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await airthings_wave_base.wave_base_to_code(var, config) 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/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index a12c70f04c..8ea79e644f 100644 --- a/esphome/components/airthings_wave_plus/sensor.py +++ b/esphome/components/airthings_wave_plus/sensor.py @@ -83,7 +83,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await airthings_wave_base.wave_base_to_code(var, config) 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/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index 279ab214cf..2c1a04ef27 100644 --- a/esphome/components/alpha3/sensor.py +++ b/esphome/components/alpha3/sensor.py @@ -20,6 +20,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType alpha3_ns = cg.esphome_ns.namespace("alpha3") Alpha3 = alpha3_ns.class_("Alpha3", ble_client.BLEClientNode, cg.PollingComponent) @@ -68,7 +69,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) 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/am2315c/sensor.py b/esphome/components/am2315c/sensor.py index ec12ab717e..febb11409c 100644 --- a/esphome/components/am2315c/sensor.py +++ b/esphome/components/am2315c/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -40,7 +41,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/am2320/sensor.py b/esphome/components/am2320/sensor.py index ed4a5fd922..ffac0e6407 100644 --- a/esphome/components/am2320/sensor.py +++ b/esphome/components/am2320/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/am43/cover/__init__.py b/esphome/components/am43/cover/__init__.py index e4ecf1444f..d1783b77df 100644 --- a/esphome/components/am43/cover/__init__.py +++ b/esphome/components/am43/cover/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ble_client, cover import esphome.config_validation as cv from esphome.const import CONF_PIN +from esphome.types import ConfigType CODEOWNERS = ["@buxtronix"] DEPENDENCIES = ["ble_client"] @@ -27,7 +28,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) cg.add(var.set_pin(config[CONF_PIN])) cg.add(var.set_invert_position(config[CONF_INVERT_POSITION])) 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/__init__.py b/esphome/components/am43/sensor/__init__.py index 2697d364ad..80341972a9 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType AUTO_LOAD = ["am43"] CODEOWNERS = ["@buxtronix"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) 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/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index 8c13727755..b2de1d6184 100644 --- a/esphome/components/analog_threshold/binary_sensor.py +++ b/esphome/components/analog_threshold/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor, sensor import esphome.config_validation as cv from esphome.const import CONF_SENSOR_ID, CONF_THRESHOLD +from esphome.types import ConfigType analog_threshold_ns = cg.esphome_ns.namespace("analog_threshold") @@ -32,7 +33,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index e9630f5266..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -1,114 +1,41 @@ -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 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 . import image as animation_image +from .image import ANIMATION_CONFIG_SCHEMA, setup_animation -AUTO_LOAD = ["image"] +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + +AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True -CONF_LOOP = "loop" -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..0265a350f7 --- /dev/null +++ b/esphome/components/animation/image.py @@ -0,0 +1,127 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image +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.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType + +CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES +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: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + 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/anova/climate.py b/esphome/components/anova/climate.py index e1fd38fddc..5590b18a83 100644 --- a/esphome/components/anova/climate.py +++ b/esphome/components/anova/climate.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ble_client, climate import esphome.config_validation as cv from esphome.const import CONF_UNIT_OF_MEASUREMENT +from esphome.types import ConfigType UNITS = { "f": "f", @@ -28,7 +29,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) 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/apds9306/sensor.py b/esphome/components/apds9306/sensor.py index c3cba96fbf..4f165eec0b 100644 --- a/esphome/components/apds9306/sensor.py +++ b/esphome/components/apds9306/sensor.py @@ -1,6 +1,8 @@ # Based on this datasheet: # https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv @@ -11,6 +13,8 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_LUX, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -55,7 +59,7 @@ AMBIENT_LIGHT_GAINS = { } -def _validate_measurement_rate(value): +def _validate_measurement_rate(value: Any) -> MockObj: value = cv.positive_time_period_milliseconds(value) return cv.enum(MEASUREMENT_RATES, int=True)(value.total_milliseconds) @@ -85,7 +89,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 99e37d3764..7ac1e5eb32 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -57,7 +58,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/apds9960/binary_sensor.py b/esphome/components/apds9960/binary_sensor.py index 48e923ab2b..342f688249 100644 --- a/esphome/components/apds9960/binary_sensor.py +++ b/esphome/components/apds9960/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await binary_sensor.new_binary_sensor(config) func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor") diff --git a/esphome/components/apds9960/sensor.py b/esphome/components/apds9960/sensor.py index 468eb0995f..a75fb79d1b 100644 --- a/esphome/components/apds9960/sensor.py +++ b/esphome/components/apds9960/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import APDS9960, CONF_APDS9960_ID @@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_APDS9960_ID]) var = await sensor.new_sensor(config) func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor") diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index ca74483a2b..2e891a9663 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,11 +1,21 @@ -import base64 import logging +import re +from typing import Any from esphome import automation from esphome.automation import Condition import esphome.codegen as cg from esphome.components.logger import request_log_listener -from esphome.config_helpers import get_logger_level + +# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external +# components and downstream consumers that import them from api +from esphome.components.noise import ( # noqa: F401 + ENCRYPTION_SCHEMA, + decode_encryption_key, + encryption_schema, + validate_encryption_key, +) +from esphome.config_helpers import filter_source_files_from_defines, get_logger_level import esphome.config_validation as cv from esphome.const import ( CONF_ACTION, @@ -13,6 +23,7 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_ENCRYPTION, CONF_EVENT, CONF_ID, CONF_KEY, @@ -36,6 +47,10 @@ from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_pr from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigFragmentType, ConfigType +# Compat alias: downstream consumers (e.g. device-builder) referenced the +# schema by its old private name before it moved to the noise component +_encryption_schema = encryption_schema + _LOGGER = logging.getLogger(__name__) DOMAIN = "api" @@ -44,9 +59,15 @@ CODEOWNERS = ["@esphome/core"] def AUTO_LOAD(config: ConfigType) -> list[str]: - """Conditionally auto-load json only when capture_response is used.""" + """Conditionally auto-load noise (encryption) and json (capture_response).""" base = ["socket"] + # A falsy config is a tooling probe for the maximal set (None from + # dependency resolution, {} from the components-graph platform probe); + # a validated config always carries defaults, never empty + if not config or CONF_ENCRYPTION in config: + base = base + ["noise"] + # Check if any homeassistant.action/homeassistant.service has capture_response: true # This flag is set during config validation in _validate_response_config if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False): @@ -102,7 +123,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { for name, t in _SERVICE_ARG_SCALAR_TYPES.items() }, } -CONF_ENCRYPTION = "encryption" CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" @@ -112,18 +132,21 @@ CONF_MAX_SEND_QUEUE = "max_send_queue" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" -def validate_encryption_key(value): - value = cv.string_strict(value) - try: - decoded = base64.b64decode(value, validate=True) - except ValueError as err: - raise cv.Invalid("Invalid key format, please check it's using base64") from err +def _register_provisioning_source(config: ConfigType) -> ConfigType: + """Register the API as a provisioning source when encryption is enabled. - if len(decoded) != 32: - raise cv.Invalid("Encryption key must be base64 and 32 bytes long") + 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 - # Return original data for roundtrip conversion - return value + provisioning.register_source("api") + if CONF_KEY in encryption: + provisioning.report_hardcoded_credentials("api") + return config CONF_SUPPORTS_RESPONSE = "supports_response" @@ -200,7 +223,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType: return config -def _validate_supports_response(value): +def _validate_supports_response(value: Any) -> str: """Validate supports_response after auto-detection has set the value.""" return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) @@ -232,18 +255,6 @@ ACTIONS_SCHEMA = automation.validate_automation( ), ) -ENCRYPTION_SCHEMA = cv.Schema( - { - cv.Optional(CONF_KEY): validate_encryption_key, - } -) - - -def _encryption_schema(config): - if config is None: - config = {} - return ENCRYPTION_SCHEMA(config) - def _consume_api_sockets(config: ConfigType) -> ConfigType: """Register socket needs for API component.""" @@ -279,7 +290,7 @@ CONFIG_SCHEMA = cv.All( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, - cv.Optional(CONF_ENCRYPTION): _encryption_schema, + cv.Optional(CONF_ENCRYPTION): encryption_schema, cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), @@ -300,21 +311,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 +337,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 +348,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), _consume_api_sockets, + _register_provisioning_source, ) @@ -373,7 +387,7 @@ async def to_code(config: ConfigType) -> None: if actions := config.get(CONF_ACTIONS, []): # Collect all triggers first, then register all at once with initializer_list - triggers: list[cg.Pvariable] = [] + triggers: list[cg.MockObj] = [] for conf in actions: func_args: list[tuple[MockObj, str]] = [] service_template_args: list[MockObj] = [] # User service argument types @@ -463,21 +477,20 @@ async def to_code(config: ConfigType) -> None: if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None: if key := encryption_config.get(CONF_KEY): - decoded = base64.b64decode(key) + decoded = decode_encryption_key(key) cg.add(var.set_noise_psk(list(decoded))) 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") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.11") - # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops - cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") - cg.add_build_flag("-DHAVE_INLINE_ASM=1") else: cg.add_define("USE_API_PLAINTEXT") @@ -487,6 +500,40 @@ async def to_code(config: ConfigType) -> None: KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)}) +_ID_CALL_PROG = re.compile(r"\bid\s*\(") + + +# Remove before 2027.3.0: untagged strings that look like lambda source keep +# being compiled as lambdas during the deprecation window +def _coerce_implicit_lambda(value: Any) -> Any: + if not isinstance(value, str): + return value + if cv.looks_like_returning_lambda(value): + _LOGGER.warning( + "[api] The 'variables' value '%s' looks like a lambda but is " + "missing the !lambda tag. It is compiled as a lambda for now but " + "will be sent as literal text from 2027.3.0. Add !lambda to keep " + "it evaluated; literal text belongs under 'data:'.", + value, + ) + # cv.templatable runs returning_lambda on the coerced Lambda + return cv.lambda_(value) + if _ID_CALL_PROG.search(value): + # lambda source without a return: issue 5394's mistake class + _LOGGER.warning( + "[api] The 'variables' value '%s' is sent as literal text; wrap " + "it in !lambda 'return ...;' to evaluate it instead.", + value, + ) + return value + + +# Static strings or !lambda values. cv.templatable stays introspectable for +# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA. +VARIABLES_SCHEMA = cv.Schema( + {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))} +) + def _validate_response_config(config: ConfigType) -> ConfigType: # Validate dependencies: @@ -523,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ), cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): cv.Schema( - {cv.string: cv.returning_lambda} - ), + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), @@ -538,24 +583,27 @@ 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, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, -): +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, False) @@ -583,6 +631,8 @@ async def homeassistant_service_to_code( cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) + if isinstance(templ, str): + templ = cg.FlashStringLiteral(templ) cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) if on_error := config.get(CONF_ON_ERROR): @@ -621,7 +671,7 @@ async def homeassistant_service_to_code( return var -def validate_homeassistant_event(value): +def validate_homeassistant_event(value: Any) -> str: value = cv.string(value) if not value.startswith("esphome."): raise cv.Invalid( @@ -637,18 +687,25 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( cv.Required(CONF_EVENT): validate_homeassistant_event, cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_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, HOMEASSISTANT_EVENT_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_event_to_code(config, action_id, template_arg, args): +async def homeassistant_event_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -676,6 +733,8 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args): cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) + if isinstance(templ, str): + templ = cg.FlashStringLiteral(templ) cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) return var @@ -696,7 +755,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA, synchronous=True, ) -async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args): +async def homeassistant_tag_scanned_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") serv = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, serv, True) @@ -712,7 +776,7 @@ CONF_SUCCESS = "success" CONF_ERROR_MESSAGE = "error_message" -def _validate_api_respond_data(config): +def _validate_api_respond_data(config: ConfigType) -> ConfigType: """Set flag during validation so AUTO_LOAD can include json component.""" if CONF_DATA in config: CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True @@ -796,18 +860,32 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema( @automation.register_condition( "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA ) -async def api_connected_to_code(config, condition_id, template_arg, args): +async def api_connected_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(condition_id, template_arg) templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) cg.add(var.set_state_subscription_only(templ)) return var +# user_services.cpp is only needed when user defined actions exist; the +# frame helpers are fully #ifdef'd on the protocol defines set in to_code +# (both are set when encryption is configured without a key). +_define_filter = filter_source_files_from_defines( + { + "user_services.cpp": "USE_API_USER_DEFINED_ACTIONS", + "api_frame_helper_noise.cpp": "USE_API_NOISE", + "api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT", + } +) + + def FILTER_SOURCE_FILES() -> list[str]: - """Filter out api_pb2_dump.cpp when proto message dumping is not enabled, - user_services.cpp when no services are defined, and protocol-specific - implementations based on encryption configuration.""" - files_to_filter: list[str] = [] + files_to_filter = _define_filter() # api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined # This is a particularly large file that still needs to be opened and read @@ -818,21 +896,4 @@ def FILTER_SOURCE_FILES() -> list[str]: if get_logger_level() != "VERY_VERBOSE": files_to_filter.append("api_pb2_dump.cpp") - # user_services.cpp is only needed when services are defined - config = CORE.config.get(DOMAIN, {}) - if config and not config.get(CONF_ACTIONS) and not config[CONF_CUSTOM_SERVICES]: - files_to_filter.append("user_services.cpp") - - # Filter protocol-specific implementations based on encryption configuration - encryption_config = config.get(CONF_ENCRYPTION) if config else None - - # If encryption is not configured at all, we only need plaintext - if encryption_config is None: - files_to_filter.append("api_frame_helper_noise.cpp") - # If encryption is configured with a key, we only need noise - elif encryption_config.get(CONF_KEY): - files_to_filter.append("api_frame_helper_plaintext.cpp") - # If encryption is configured but no key is provided, we need both - # (this allows a plaintext client to provide a noise key) - return files_to_filter diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f4f15c1042..c11700782e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -19,6 +19,7 @@ service APIConnection { rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) { option (needs_authentication) = false; } + rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -158,6 +159,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 +177,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 { @@ -217,6 +232,7 @@ enum SerialProxyPortType { message SerialProxyInfo { string name = 1; // Human-readable port name SerialProxyPortType port_type = 2; // Port type (RS232, RS485) + uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive } // DeviceInfoResponse max_data_length values: @@ -229,6 +245,12 @@ message SerialProxyInfo { // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) // suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) +// +// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They +// have moved to that message as of API 1.15, but are still sent here so that +// older clients keep working. Do NOT mark them (deprecated) until the removal +// release: in this repo (deprecated) makes the generator drop the field +// entirely, so the device would stop sending it. message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -266,6 +288,8 @@ message DeviceInfoResponse { // Deprecated in API version 1.9 uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; + + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; string manufacturer = 12 [(max_data_length) = 20, (force) = true]; @@ -274,11 +298,14 @@ message DeviceInfoResponse { // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; + + // Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15. uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15. string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key @@ -291,11 +318,76 @@ message DeviceInfoResponse { AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; // Indicates if Z-Wave proxy support is available and features supported + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; // Serial proxy instance metadata + // Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15. 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"]; +} + +// ==================== DEVICE CAPABILITIES ==================== + +// Asks the device which optional features it supports. +// +// This message exists so that DeviceInfoResponse does not have to keep growing +// a flat list of feature flags. DeviceInfoResponse is served before +// authentication, so it is limited to identity information. Capabilities are +// only served on an authenticated connection (encrypted as well, when +// encryption is configured). +// +// Clients that see api_version >= 1.15 should read these values from +// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields. +// Older clients keep reading DeviceInfoResponse, which still carries the same +// values, so this is not a breaking change. +message DeviceCapabilitiesRequest { + option (id) = 149; + option (source) = SOURCE_CLIENT; + // Empty +} + +// Each feature gets its own sub-message so that it can gain fields over time +// without crowding the top-level field numbering. +// +// Note: a sub-message whose fields are all at their default value is not sent +// at all, so the presence of a sub-message is not a reliable test for "this +// feature is compiled in". Clients should test a value inside it, for example +// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse. + +message BluetoothProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + string mac_address = 2 [(max_data_length) = 17, (force) = true]; +} + +message VoiceAssistantCapabilities { + // Bitmask of the features this voice assistant supports + uint32 feature_flags = 1; +} + +message ZWaveProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + uint32 home_id = 2; +} + +message DeviceCapabilitiesResponse { + option (id) = 150; + option (source) = SOURCE_SERVER; + + BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; + ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + repeated SerialProxyInfo serial_proxies = 4 + [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; } message ListEntitiesRequest { @@ -911,8 +1003,12 @@ message GetTimeResponse { option (no_delay) = true; fixed32 epoch_seconds = 1; - string timezone = 2; - ParsedTimezone parsed_timezone = 3; + // Deprecated in 2026.9.0: clients still send this string for older firmware, + // but new firmware only reads parsed_timezone. Clients older than Home + // Assistant 2026.3.0 that send only the string leave the device on its + // codegen-configured timezone (or UTC). + string timezone = 2 [deprecated = true]; + ParsedTimezone parsed_timezone = 3 [(track_presence) = true]; } // ==================== USER-DEFINES SERVICES ==================== @@ -1558,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; - bool supports_pause = 8; + // Deprecated in ESPHome 2026.9.0; use feature_flags instead. + bool supports_pause = 8 [deprecated = true]; repeated MediaPlayerSupportedFormat supported_formats = 9; @@ -1669,7 +1766,7 @@ enum BluetoothDeviceRequestType { message BluetoothDeviceRequest { option (id) = 68; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; BluetoothDeviceRequestType request_type = 2; @@ -1680,7 +1777,7 @@ message BluetoothDeviceRequest { message BluetoothDeviceConnectionResponse { option (id) = 69; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool connected = 2; @@ -1691,7 +1788,7 @@ message BluetoothDeviceConnectionResponse { message BluetoothGATTGetServicesRequest { option (id) = 70; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1735,7 +1832,7 @@ message BluetoothGATTService { message BluetoothGATTGetServicesResponse { option (id) = 71; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; repeated BluetoothGATTService services = 2; @@ -1744,7 +1841,7 @@ message BluetoothGATTGetServicesResponse { message BluetoothGATTGetServicesDoneResponse { option (id) = 72; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1752,7 +1849,7 @@ message BluetoothGATTGetServicesDoneResponse { message BluetoothGATTReadRequest { option (id) = 73; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1761,7 +1858,7 @@ message BluetoothGATTReadRequest { message BluetoothGATTReadResponse { option (id) = 74; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1773,7 +1870,7 @@ message BluetoothGATTReadResponse { message BluetoothGATTWriteRequest { option (id) = 75; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1785,7 +1882,7 @@ message BluetoothGATTWriteRequest { message BluetoothGATTReadDescriptorRequest { option (id) = 76; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1794,7 +1891,7 @@ message BluetoothGATTReadDescriptorRequest { message BluetoothGATTWriteDescriptorRequest { option (id) = 77; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1805,7 +1902,7 @@ message BluetoothGATTWriteDescriptorRequest { message BluetoothGATTNotifyRequest { option (id) = 78; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1815,7 +1912,7 @@ message BluetoothGATTNotifyRequest { message BluetoothGATTNotifyDataResponse { option (id) = 79; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1826,13 +1923,13 @@ message BluetoothGATTNotifyDataResponse { message SubscribeBluetoothConnectionsFreeRequest { option (id) = 80; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; } message BluetoothConnectionsFreeResponse { option (id) = 81; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint32 free = 1; uint32 limit = 2; @@ -1845,7 +1942,7 @@ message BluetoothConnectionsFreeResponse { message BluetoothGATTErrorResponse { option (id) = 82; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1855,7 +1952,7 @@ message BluetoothGATTErrorResponse { message BluetoothGATTWriteResponse { option (id) = 83; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1864,7 +1961,7 @@ message BluetoothGATTWriteResponse { message BluetoothGATTNotifyResponse { option (id) = 84; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1873,7 +1970,7 @@ message BluetoothGATTNotifyResponse { message BluetoothDevicePairingResponse { option (id) = 85; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool paired = 2; @@ -1883,7 +1980,7 @@ message BluetoothDevicePairingResponse { message BluetoothDeviceUnpairingResponse { option (id) = 86; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -1899,7 +1996,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest { message BluetoothDeviceClearCacheResponse { option (id) = 88; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -2531,6 +2628,22 @@ message ZWaveProxyRequest { bytes data = 2; } +enum ZWaveProxyStatus { + ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully + ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed + ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported +} + +// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16. +message ZWaveProxyRequestResponse { + option (id) = 151; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_ZWAVE_PROXY"; + + ZWaveProxyRequestType type = 1; // Which request type this responds to + ZWaveProxyStatus status = 2; // Result status +} + // ==================== INFRARED ==================== // Note: Feature and capability flag enums are defined in // esphome/components/infrared/infrared.h @@ -2674,12 +2787,18 @@ message SerialProxyGetModemPinsResponse { uint32 instance = 1; // Instance index (0-based) uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags + SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16) } enum SerialProxyRequestType { SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent) + // Values below are only valid in SerialProxyRequestResponse.type, identifying which + // operation is being acknowledged. Sending them in SerialProxyRequest.type is an + // error the device answers with INVALID_ARGUMENT. + SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest + SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest } enum SerialProxyStatus { @@ -2688,6 +2807,8 @@ enum SerialProxyStatus { SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance + SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port + SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value } // Generic request message for simple serial proxy operations @@ -2700,7 +2821,9 @@ message SerialProxyRequest { SerialProxyRequestType type = 2; // Request type } -// Response to a SerialProxyRequest (e.g. flush completion or failure) +// Acknowledges a serial proxy operation; the type field identifies which +// operation is being acknowledged. Flush has been acknowledged since the +// message was introduced; all other acknowledgements are sent since API 1.16. message SerialProxyRequestResponse { option (id) = 147; option (source) = SOURCE_SERVER; @@ -2716,7 +2839,7 @@ message SerialProxyRequestResponse { message BluetoothSetConnectionParamsRequest { option (id) = 145; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 min_interval = 2; // units of 1.25ms @@ -2728,7 +2851,7 @@ message BluetoothSetConnectionParamsRequest { message BluetoothSetConnectionParamsResponse { option (id) = 146; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; int32 error = 2; diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index 6db18b0365..fc45a4e971 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,13 +1,20 @@ #include "api_buffer.h" +#include namespace esphome::api { -void APIBuffer::grow_(size_t n) { - auto new_data = make_buffer(n); +bool APIBuffer::grow_(size_t n) { + // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead + // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF). + // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory. + std::unique_ptr new_data(new (std::nothrow) uint8_t[n]); + if (new_data == nullptr) + return false; if (this->size_) std::memcpy(new_data.get(), this->data_.get(), this->size_); this->data_ = std::move(new_data); this->capacity_ = n; + return true; } } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 1d0cccf61c..396dadbe58 100644 --- a/esphome/components/api/api_buffer.h +++ b/esphome/components/api/api_buffer.h @@ -9,16 +9,6 @@ namespace esphome::api { -/// Helper to use make_unique_for_overwrite where available (skips zero-fill), -/// falling back to make_unique on older GCC (ESP8266, LibreTiny). -inline std::unique_ptr make_buffer(size_t n) { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) - return std::make_unique(n); -#else - return std::make_unique_for_overwrite(n); -#endif -} - /// Byte buffer that skips zero-initialization on resize(). /// /// std::vector::resize() zero-fills new bytes via memset. For the @@ -36,23 +26,23 @@ inline std::unique_ptr make_buffer(size_t n) { class APIBuffer { public: void clear() { this->size_ = 0; } - inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE { - if (n > this->capacity_) - this->grow_(n); - } - inline void resize(size_t n) ESPHOME_ALWAYS_INLINE { - this->reserve(n); - this->size_ = n; // no zero-fill - } + /// Returns false if allocation fails; the buffer is left unchanged. + [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); } + /// Returns false if allocation fails; the buffer is left unchanged. No zero-fill. + [[nodiscard]] inline bool resize(size_t n) ESPHOME_ALWAYS_INLINE { return this->reserve_and_resize(n, n); } /// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size. /// Single grow_ check regardless of argument order. - inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { - this->reserve(std::max(reserve_size, new_size)); + /// Returns false if allocation fails; the buffer is left unchanged. + [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE { + if (!this->reserve(std::max(reserve_size, new_size))) + return false; this->size_ = new_size; + return true; } uint8_t *data() { return this->data_.get(); } const uint8_t *data() const { return this->data_.get(); } size_t size() const { return this->size_; } + size_t capacity() const { return this->capacity_; } bool empty() const { return this->size_ == 0; } uint8_t &operator[](size_t i) { return this->data_[i]; } const uint8_t &operator[](size_t i) const { return this->data_[i]; } @@ -64,7 +54,7 @@ class APIBuffer { } protected: - void grow_(size_t n); + bool grow_(size_t n); std::unique_ptr data_; size_t size_{0}; size_t capacity_{0}; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b6f4aa2141..bc088ca473 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,5 +1,6 @@ #include "api_connection.h" #ifdef USE_API +#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif @@ -22,8 +23,12 @@ #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.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" @@ -84,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static const char *const TAG = "api.connection"; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what) { + esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"), + LOG_STR_ARG(what)); +} +#endif #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; #endif @@ -148,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa #else #error "No frame helper defined" #endif -#ifdef USE_CAMERA - if (camera::Camera::instance() != nullptr) { - this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; - } -#endif } void APIConnection::start() { @@ -194,6 +201,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: @@ -252,6 +282,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 { @@ -373,15 +412,15 @@ 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_(); - while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) { - iterator.advance(); - } + // Budget by remaining batch capacity so a pass cannot overfill the batch; + // stops early on a refused send and resumes next loop pass + size_t batch_size = this->deferred_batch_.size(); + if (batch_size < MAX_INITIAL_BATCH_SIZE) + iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size); - // If the batch is full, process it immediately - // Note: iterator.advance() already calls schedule_batch_() via schedule_message_() - if (this->deferred_batch_.size() >= max_batch) { + // Flush immediately once enough is queued (not guaranteed every pass); + // partial batches go out via the batch timer or finalize_iterator_sync_() + if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) { this->process_batch_(); } } @@ -417,16 +456,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(); } @@ -768,6 +797,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION); // Current feature flags and other supported parameters msg.feature_flags = traits.get_feature_flags(); + msg.temperature_unit = static_cast(traits.get_temperature_unit()); msg.supported_modes = &traits.get_supported_modes(); msg.visual_min_temperature = traits.get_visual_min_temperature(); msg.visual_max_temperature = traits.get_visual_max_temperature(); @@ -1069,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec auto *media_player = static_cast(entity); ListEntitiesMediaPlayerResponse msg; auto traits = media_player->get_traits(); - msg.supports_pause = traits.get_supports_pause(); msg.feature_flags = traits.get_feature_flags(); for (auto &supported_format : traits.get_supported_formats()) { msg.supported_formats.emplace_back(); @@ -1105,6 +1134,7 @@ void APIConnection::try_send_camera_image_() { if (!this->image_reader_) return; + const auto *cam = camera::Camera::instance(); // Send as many chunks as possible without blocking while (this->image_reader_->available()) { if (!this->helper_->can_write_without_blocking()) @@ -1114,11 +1144,11 @@ void APIConnection::try_send_camera_image_() { bool done = this->image_reader_->available() == to_send; CameraImageResponse msg; - msg.key = camera::Camera::instance()->get_object_id_hash(); + msg.key = cam->get_object_id_hash(); msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.done = done; #ifdef USE_DEVICES - msg.device_id = camera::Camera::instance()->get_device_id(); + msg.device_id = cam->get_device_id(); #endif if (!this->send_message(msg)) { @@ -1134,15 +1164,19 @@ void APIConnection::try_send_camera_image_() { void APIConnection::set_camera_state(std::shared_ptr image) { if (!this->flags_.state_subscription) return; - if (!this->image_reader_) + if (this->image_reader_ && this->image_reader_->available()) return; - if (this->image_reader_->available()) + if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE)) return; - if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) { - this->image_reader_->set_image(std::move(image)); - // Try to send immediately to reduce latency - this->try_send_camera_image_(); + if (!this->image_reader_) { + // Created on the first image this connection will send, so connections + // that never receive one never pay for a reader. Only a registered + // camera's listener can reach this, so instance() is non-null here. + this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()}; } + this->image_reader_->set_image(std::move(image)); + // Try to send immediately to reduce latency + this->try_send_camera_image_(); } uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *camera = static_cast(entity); @@ -1168,32 +1202,29 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) { void APIConnection::on_get_time_response(const GetTimeResponse &value) { if (homeassistant::global_homeassistant_time != nullptr) { homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds); -#ifdef USE_TIME_TIMEZONE - if (!value.timezone.empty()) { - // Check if the sender provided pre-parsed timezone data. - // If std_offset is non-zero or DST rules are present, the parsed data was populated. - // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent. +#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE) + // Apply only if the sender provided pre-parsed timezone data (Home Assistant 2026.3.0 + // and newer); field presence distinguishes a genuine all-zero UTC timezone from an + // absent field. Older clients send only the deprecated timezone string, which is no + // longer decoded; for them the device keeps its codegen-configured timezone. + if (value.has_parsed_timezone) { const auto &pt = value.parsed_timezone; - if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) { - time::ParsedTimezone tz{}; - tz.std_offset_seconds = pt.std_offset_seconds; - tz.dst_offset_seconds = pt.dst_offset_seconds; - tz.dst_start.time_seconds = pt.dst_start.time_seconds; - tz.dst_start.day = static_cast(pt.dst_start.day); - tz.dst_start.type = static_cast(pt.dst_start.type); - tz.dst_start.month = static_cast(pt.dst_start.month); - tz.dst_start.week = static_cast(pt.dst_start.week); - tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); - tz.dst_end.time_seconds = pt.dst_end.time_seconds; - tz.dst_end.day = static_cast(pt.dst_end.day); - tz.dst_end.type = static_cast(pt.dst_end.type); - tz.dst_end.month = static_cast(pt.dst_end.month); - tz.dst_end.week = static_cast(pt.dst_end.week); - tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); - time::set_global_tz(tz); - } else { - homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size()); - } + time::ParsedTimezone tz{}; + tz.std_offset_seconds = pt.std_offset_seconds; + tz.dst_offset_seconds = pt.dst_offset_seconds; + tz.dst_start.time_seconds = pt.dst_start.time_seconds; + tz.dst_start.day = static_cast(pt.dst_start.day); + tz.dst_start.type = static_cast(pt.dst_start.type); + tz.dst_start.month = static_cast(pt.dst_start.month); + tz.dst_start.week = static_cast(pt.dst_start.week); + tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week); + tz.dst_end.time_seconds = pt.dst_end.time_seconds; + tz.dst_end.day = static_cast(pt.dst_end.day); + tz.dst_end.type = static_cast(pt.dst_end.type); + tz.dst_end.month = static_cast(pt.dst_end.month); + tz.dst_end.week = static_cast(pt.dst_end.week); + tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week); + time::set_global_tz(tz); } #endif } @@ -1208,6 +1239,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request( void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); } @@ -1241,13 +1273,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() { } } +void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { + bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); +} +#endif + void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); } -void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { - bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); -} #endif #ifdef USE_VOICE_ASSISTANT @@ -1302,9 +1336,13 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno } } -bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) { +bool APIConnection::send_voice_assistant_get_configuration_response_( + const VoiceAssistantConfigurationRequest & /*msg*/) { VoiceAssistantConfigurationResponse resp; if (!this->check_voice_assistant_api_connection_()) { + // send_message encodes synchronously, so this stack local outlives the encode + const std::vector empty_wake_words; + resp.active_wake_words = &empty_wake_words; return this->send_message(resp); } @@ -1319,22 +1357,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice } } - // Filter external wake words - for (auto &wake_word : msg.external_wake_words) { - if (wake_word.model_type != "micro") { - // microWakeWord only - continue; - } - - resp.available_wake_words.emplace_back(); - auto &resp_wake_word = resp.available_wake_words.back(); - resp_wake_word.id = StringRef(wake_word.id); - resp_wake_word.wake_word = StringRef(wake_word.wake_word); - for (const auto &lang : wake_word.trained_languages) { - resp_wake_word.trained_languages.push_back(lang); - } - } - resp.active_wake_words = &config.active_wake_words; resp.max_active_wake_words = config.max_active_wake_words; return this->send_message(resp); @@ -1354,11 +1376,16 @@ 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) { - zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type); + ZWaveProxyRequestResponse resp{}; + resp.type = msg.type; + resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response"); + } } #endif @@ -1439,6 +1466,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec msg.target_temperature_step = traits.get_target_temperature_step(); msg.supported_modes = &traits.get_supported_modes(); msg.supported_features = traits.get_feature_flags(); + msg.temperature_unit = static_cast(traits.get_temperature_unit()); return fill_and_encode_entity_info(wh, msg, conn, remaining_size); } @@ -1516,19 +1544,60 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } +void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { + if (!this->send_message(msg)) { + // V: fires per decoded frame with no subscription gate, so a warning + // would flood the congested link it reports on. + ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); + } +} #endif #ifdef USE_SERIAL_PROXY +static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) { + switch (result) { + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK: + return enums::SERIAL_PROXY_STATUS_OK; + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS: + return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS; + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE: + return enums::SERIAL_PROXY_STATUS_PORT_IN_USE; + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT: + return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT: + return enums::SERIAL_PROXY_STATUS_TIMEOUT; + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED: + return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED; + case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR: + return enums::SERIAL_PROXY_STATUS_ERROR; + } + return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above +} + +static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type, + enums::SerialProxyStatus status) { + SerialProxyRequestResponse resp{}; + resp.instance = instance; + resp.type = type; + resp.status = status; + if (!conn->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } +} + void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance, static_cast(proxies.size())); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); return; } - proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, - msg.data_size); + serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure( + this, msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, msg.data_size); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE, + serial_proxy_result_to_status(result)); } void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { @@ -1537,69 +1606,78 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } - proxies[msg.instance]->write_from_client(msg.data, msg.data_len); + proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len); } void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); return; } - proxies[msg.instance]->set_modem_pins(msg.line_states); + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS, + serial_proxy_result_to_status(result)); } void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { auto &proxies = App.get_serial_proxies(); - if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); - return; - } SerialProxyGetModemPinsResponse resp{}; resp.instance = msg.instance; - resp.line_states = proxies[msg.instance]->get_modem_pins(); - this->send_message(resp); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + // Pre-1.16 clients do not read the status field and would take this error + // for a successful "both pins deasserted" answer; let them time out as before + if (!this->client_supports_api_version(1, 16)) { + return; + } + resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; + } else { + resp.line_states = proxies[msg.instance]->get_modem_pins(); + } + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } } void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); return; } + auto *proxy = proxies[msg.instance]; + enums::SerialProxyStatus status; switch (msg.type) { case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE: case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE: - proxies[msg.instance]->serial_proxy_request(this, msg.type); + status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type)); break; - case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: { - SerialProxyRequestResponse resp{}; - resp.instance = msg.instance; - resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH; - switch (proxies[msg.instance]->flush_port()) { - case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS: - resp.status = enums::SERIAL_PROXY_STATUS_OK; - break; - case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS: - resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS; - break; - case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT: - resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT; - break; - case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED: - resp.status = enums::SERIAL_PROXY_STATUS_ERROR; - break; - } - this->send_message(resp); + case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: + status = serial_proxy_result_to_status(proxy->flush_port(this)); + break; + case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + // Response-only discriminators; never valid in a request + ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type)); + status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; break; - } default: ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(msg.type)); + status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED; break; } + send_serial_proxy_ack(this, msg.instance, msg.type, status); } -void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { + if (!this->send_message(msg)) { + ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); + } +} #endif #ifdef USE_INFRARED @@ -1711,25 +1789,36 @@ void APIConnection::complete_authentication_() { bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); - this->client_api_version_major_ = msg.api_version_major; - this->client_api_version_minor_ = msg.api_version_minor; + this->client_api_version_major_ = + static_cast(std::min(msg.api_version_major, std::numeric_limits::max())); + this->client_api_version_minor_ = + static_cast(std::min(msg.api_version_minor, std::numeric_limits::max())); char peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(), + ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(), this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_); - // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal) - if (!this->client_supports_api_version(1, 14)) { - ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(), - this->client_api_version_major_, this->client_api_version_minor_); - } - HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 14; + resp.api_version_minor = 16; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise 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")); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Hello response"); + } + 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_(); @@ -1748,9 +1837,8 @@ bool APIConnection::send_device_info_response_() { #ifdef USE_AREAS resp.suggested_area = StringRef(App.get_area()); #endif - // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char mac_address[18]; - uint8_t mac[6]; + char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); @@ -1765,7 +1853,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" @@ -1826,8 +1914,7 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char bluetooth_mac[18]; + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac); #endif @@ -1846,10 +1933,17 @@ bool APIConnection::send_device_info_response_() { auto &info = resp.serial_proxies[serial_proxy_index++]; info.name = StringRef(proxy->get_name()); info.port_type = proxy->get_port_type(); + info.configured_line_states = proxy->get_configured_modem_pins(); } #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; @@ -1875,12 +1969,43 @@ bool APIConnection::send_device_info_response_() { return this->send_message(resp); } +bool APIConnection::send_device_capabilities_response_() { + // These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks + // below in sync with send_device_info_response_() until those copies are removed. + DeviceCapabilitiesResponse resp; +#ifdef USE_BLUETOOTH_PROXY + resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); + resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac); +#endif +#ifdef USE_VOICE_ASSISTANT + resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); +#endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); + resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id(); +#endif +#ifdef USE_SERIAL_PROXY + size_t serial_proxy_index = 0; + for (auto const &proxy : App.get_serial_proxies()) { + if (serial_proxy_index >= SERIAL_PROXY_COUNT) + break; + auto &info = resp.serial_proxies[serial_proxy_index++]; + info.name = StringRef(proxy->get_name()); + info.port_type = proxy->get_port_type(); + info.configured_line_states = proxy->get_configured_modem_pins(); + } +#endif + return this->send_message(resp); +} void APIConnection::on_hello_request(const HelloRequest &msg) { if (!this->send_hello_response_(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(); } @@ -1895,6 +2020,11 @@ void APIConnection::on_device_info_request() { this->on_fatal_error(); } } +void APIConnection::on_device_capabilities_request() { + if (!this->send_device_capabilities_response_()) { + this->on_fatal_error(); + } +} #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { @@ -1973,7 +2103,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.call_id = call_id; resp.success = success; resp.error_message = error_message; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message, @@ -1984,12 +2116,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.error_message = error_message; resp.response_data = response_data; resp.response_data_len = response_data_len; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES +bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) { + if (!this->flags_.service_call_subscription) + return false; + if (!this->send_message(call)) { + API_LOG_MSG_DROPPED(TAG, "Action request"); + } + return true; +} +#endif // USE_API_HOMEASSISTANT_SERVICES + +#ifdef USE_HOMEASSISTANT_TIME +void APIConnection::send_time_request() { + GetTimeRequest req; + if (!this->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Time request"); + } +} +#endif // USE_HOMEASSISTANT_TIME + #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON @@ -2008,7 +2162,16 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio NoiseEncryptionSetKeyResponse resp; resp.success = false; - psk_t psk{}; +#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 + + noise::psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { resp.success = true; @@ -2017,10 +2180,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 (noise::NoiseContext::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); @@ -2044,11 +2218,14 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { if (this->helper_->can_write_without_blocking()) return true; if (log_out_of_space) { - ESP_LOGV(TAG, "Cannot send message because of TCP buffer space"); + // VV: refusals are either reported by the sending call site (naming what + // was lost) or retried without loss (the deferred batch), so this generic + // line only duplicates them. + ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space"); } return false; } -bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, +bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg) { #ifdef HAS_PROTO_MESSAGE_DUMP // Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise) @@ -2062,10 +2239,17 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); } #endif + if (!this->prepare_first_message_buffer(payload_size)) [[unlikely]] { + this->fatal_out_of_memory_(); + return false; + } auto &shared_buf = this->parent_->get_shared_buffer_ref(); - this->prepare_first_message_buffer(shared_buf, payload_size); size_t write_start = shared_buf.size(); - shared_buf.resize(write_start + payload_size); +#ifdef ESPHOME_DEBUG_API + assert(shared_buf.capacity() >= write_start + payload_size); +#endif + // Capacity reserved above, cannot fail + (void) shared_buf.resize(write_start + payload_size); ProtoWriteBuffer buffer{&shared_buf, write_start}; encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type); @@ -2077,7 +2261,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE APIConnection *conn, uint32_t remaining_size) { return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size); } -bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { +bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) { const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE); if (!this->try_to_clear_buffer(!is_log_message)) { @@ -2101,30 +2285,42 @@ void APIConnection::on_no_setup_connection() { this->on_fatal_error(); this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup")); } +void APIConnection::fatal_out_of_memory_() { + this->fatal_error_with_log_(LOG_STR("Out of memory"), APIError::OUT_OF_MEMORY); +} void APIConnection::on_fatal_error() { // Don't close socket here - keep it open so getpeername() works for logging // Socket will be closed when client is removed from the list in APIServer::loop() this->flags_.remove = true; } -bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { +bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) { this->deferred_batch_.add_item_front(entity, message_type, estimated_size); return this->schedule_batch_(); } -bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, +bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { - auto &shared_buf = this->parent_->get_shared_buffer_ref(); - this->prepare_first_message_buffer(shared_buf, estimated_size); + // No local for the shared buffer here: keeping it live across + // dispatch_message_ costs a register and spills message_type into the + // batching path's dedup loop (measured on x86 GCC -Os) + if (!this->prepare_first_message_buffer(estimated_size)) [[unlikely]] { + this->fatal_out_of_memory_(); + return false; + } DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index}; if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) && - this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type)) { + this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_batch_item_(item); #endif return true; } + // An OOM during the immediate attempt marks the connection for removal; + // don't queue more work (schedule_message_'s push_back may allocate again) + if (this->flags_.remove) [[unlikely]] + return false; } return this->schedule_message_(entity, message_type, estimated_size, aux_data_index); } @@ -2174,7 +2370,11 @@ void APIConnection::process_batch_() { total_estimated_size = MAX_BATCH_PACKET_SIZE; } - this->prepare_first_message_buffer(shared_buf, header_padding, total_estimated_size); + if (!this->prepare_first_message_buffer(header_padding, total_estimated_size)) [[unlikely]] { + this->fatal_out_of_memory_(); + this->clear_batch_(); + return; + } // Fast path for single message - buffer already allocated above if (num_items == 1) { @@ -2189,8 +2389,10 @@ void APIConnection::process_batch_() { #endif this->clear_batch_(); } else if (payload_size == 0) { - // Message too large to fit in available space - ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type); + // payload_size == 0 with remove set means encoding hit OOM and the + // connection is being dropped; warn only for a genuinely oversized message + if (!this->flags_.remove) + ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type); this->clear_batch_(); } return; @@ -2253,8 +2455,10 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items if (items_processed > 0) { // Add footer space for the last message (for Noise protocol MAC) - if (footer_size > 0) { - shared_buf.resize(shared_buf.size() + footer_size); + if (footer_size > 0 && !shared_buf.resize(shared_buf.size() + footer_size)) [[unlikely]] { + this->fatal_out_of_memory_(); + this->clear_batch_(); + return; } // Send all collected messages diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 4165b7f3a2..a4c49dccf4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -11,19 +11,21 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" -#include "api_server.h" +#include "list_entities.h" +#include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" #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" #endif #include "esphome/core/entity_base.h" +#include "esphome/core/log.h" #include "esphome/core/string_ref.h" #include @@ -36,16 +38,26 @@ class ComponentIterator; namespace esphome::api { +// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. +class APIServer; + +// One shared flash string for every refused-frame warning: send_message() +// fails as soon as the TCP buffer is full, and each caller only pays for its +// short name. The guard drops the helper and its arguments below WARN. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what); +#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what)) +#else +#define API_LOG_MSG_DROPPED(tag, what) +#endif + // 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) +// Deferred batch size cap during initial state/info sync +static constexpr size_t MAX_INITIAL_BATCH_SIZE = 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"); +static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE, + "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE"); #ifdef USE_BENCHMARK class APIConnection; @@ -165,11 +177,10 @@ 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) { - if (!this->flags_.service_call_subscription) - return; - this->send_message(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); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -178,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg); void on_unsubscribe_bluetooth_le_advertisements_request(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &msg); void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg); void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg); @@ -186,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase { void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg); void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg); void on_subscribe_bluetooth_connections_free_request(); - void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg); +#endif + void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); #endif #ifdef USE_HOMEASSISTANT_TIME - void send_time_request() { - GetTimeRequest req; - this->send_message(req); - } + void send_time_request(); #endif #ifdef USE_VOICE_ASSISTANT @@ -258,9 +268,10 @@ 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_device_capabilities_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -278,8 +289,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(); @@ -315,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase { bool is_marked_for_removal() const { return this->flags_.remove; } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } - // Get client API version for feature detection - bool client_supports_api_version(uint16_t major, uint16_t minor) const { + // Get client API version for feature detection. + // Stored versions saturate at 255 (see send_hello_response_), so requesting + // a minimum above that can never match. + bool client_supports_api_version(uint8_t major, uint8_t minor) const { return this->client_api_version_major_ > major || (this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor); } @@ -329,7 +342,9 @@ class APIConnection final : public APIServerConnectionBase { // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); - template bool send_message(const T &msg) { + /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we + /// have no silent failures: every caller must handle (or log) a refusal. + template [[nodiscard]] bool send_message(const T &msg) { if constexpr (T::ESTIMATED_SIZE == 0) { return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); } else { @@ -337,22 +352,13 @@ class APIConnection final : public APIServerConnectionBase { } } - void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) { - shared_buf.clear(); - // Reserve space for header padding + message + footer - // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) - // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) - // Reserve full size but only set initial size to header padding - // so message encoding starts at the correct position - shared_buf.reserve_and_resize(total_size, header_padding); - } + /// Clear the shared write buffer and reserve space for the first message. + /// Returns false if the allocation fails (out of memory). + /// Defined in api_connection_buffer.h (needs APIServer complete). + [[nodiscard]] bool prepare_first_message_buffer(size_t header_padding, size_t total_size); // Convenience overload - computes frame overhead internally - void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) { - const uint8_t header_padding = this->helper_->frame_header_padding(); - const uint8_t footer_size = this->helper_->frame_footer_size(); - this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size); - } + [[nodiscard]] bool prepare_first_message_buffer(size_t payload_size); bool try_to_clear_buffer(bool log_out_of_space) { if (this->flags_.remove) @@ -361,7 +367,7 @@ class APIConnection final : public APIServerConnectionBase { return true; return this->try_to_clear_buffer_slow_(log_out_of_space); } - bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type); + bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type); const char *get_name() const { return this->helper_->get_client_name(); } /// Get peer name (IP address) into caller-provided buffer, returns buf for convenience @@ -380,10 +386,11 @@ class APIConnection final : public APIServerConnectionBase { bool send_disconnect_response_(); bool send_ping_response_(); bool send_device_info_response_(); + bool send_device_capabilities_response_(); #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool send_subscribe_bluetooth_connections_free_response_(); #endif #ifdef USE_VOICE_ASSISTANT @@ -409,46 +416,12 @@ class APIConnection final : public APIServerConnectionBase { } // Non-template buffer management for send_message - bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); + bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg); - // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes. - // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites. - static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, - const void *msg, APIConnection *conn, - uint32_t remaining_size) { -#ifdef HAS_PROTO_MESSAGE_DUMP - if (conn->flags_.log_only_mode) { - auto *proto_msg = static_cast(msg); - DumpBuffer dump_buf; - conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); - return 1; - } -#endif - const uint8_t footer_size = conn->helper_->frame_footer_size(); - - // First message uses max padding (already in buffer), subsequent use exact header size - size_t to_add; - if (conn->flags_.batch_first_message) { - conn->flags_.batch_first_message = false; - conn->batch_header_size_ = conn->helper_->frame_header_padding(); - to_add = calculated_size; - } else { - conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); - to_add = calculated_size + conn->batch_header_size_ + footer_size; - } - - // Check if it fits (using actual header size, not max padding) - uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; - if (total_calculated_size > remaining_size) - return 0; - - auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); - ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; - encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); - - return total_calculated_size; - } + // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. + // Defined in api_connection_buffer.h (needs APIServer complete). + static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, + const void *msg, APIConnection *conn, uint32_t remaining_size); // Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages). // All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion. @@ -511,13 +484,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); @@ -666,6 +632,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 @@ -686,10 +657,9 @@ class APIConnection final : public APIServerConnectionBase { struct BatchItem { EntityBase *entity; // 4 bytes - Entity pointer - uint8_t message_type; // 1 byte - Message type for protocol and dispatch + uint16_t message_type; // 2 bytes - Message type for protocol and dispatch uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes) uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types - // 1 byte padding }; std::vector items; @@ -699,7 +669,7 @@ class APIConnection final : public APIServerConnectionBase { // connections that do, buffers are released after initial sync anyway // Add item to the batch (with deduplication) - void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, + void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index = AUX_DATA_UNUSED) { // Dedup: O(n) scan but optimized for RAM over performance // Skip deduplication for events - they are edge-triggered, every occurrence matters @@ -715,7 +685,7 @@ class APIConnection final : public APIServerConnectionBase { this->items.push_back({entity, message_type, estimated_size, aux_data_index}); } // Add item to the front of the batch (for high priority messages like ping) - void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) { // Swap to front avoids expensive vector::insert which shifts all elements this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); if (this->items.size() > 1) { @@ -780,19 +750,22 @@ class APIConnection final : public APIServerConnectionBase { #endif } flags_{}; // 2 bytes total - // 2-byte types immediately after flags_ (no padding between them) - uint16_t client_api_version_major_{0}; - uint16_t client_api_version_minor_{0}; + // 2-byte type immediately after flags_ (no padding between them) + uint16_t batch_message_type_{0}; // Current message type during batch encoding // 1-byte types to fill remaining space before next 4-byte boundary + // Client API versions are clamped to 255 on receive (see send_hello_response_) + uint8_t client_api_version_major_{0}; + uint8_t client_api_version_minor_{0}; ActiveIterator active_iterator_{ActiveIterator::NONE}; - uint8_t batch_message_type_{0}; // Current message type during batch encoding - // Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary + // Total: 2 (flags) + 2 + 1 + 1 + 1 + 1 (batch_header_size_ below) = 8 bytes, + // aligned to 4-byte boundary // Actual header size used by encode_to_buffer for the current message. // Read by process_batch_multi_ to pass into MessageInfo. uint8_t batch_header_size_{0}; - uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + // Defined in api_connection_buffer.h (needs APIServer complete). + uint32_t get_batch_delay_ms_() const; // Message will use 8 more bytes than the minimum size, and typical // MTU is 1500. Sometimes users will see as low as 1460 MTU. // If its IPv6 the header is 40 bytes, and if its IPv4 @@ -834,7 +807,7 @@ class APIConnection final : public APIServerConnectionBase { // 2. It's an EventResponse (events are edge-triggered - every occurrence matters) // 3. OR: User has opted into immediate sending (should_try_send_immediately = true // AND batch_delay = 0) - inline bool should_send_immediately_(uint8_t message_type) const { + inline bool should_send_immediately_(uint16_t message_type) const { return ( #ifdef USE_UPDATE message_type == UpdateStateResponse::MESSAGE_TYPE || @@ -848,11 +821,11 @@ class APIConnection final : public APIServerConnectionBase { // Helper method to send a message either immediately or via batching // Tries immediate send if should_send_immediately_() returns true and buffer has space // Falls back to batching if immediate send fails or isn't applicable - bool send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, + bool send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED); // Helper function to schedule a deferred message with known message type - bool schedule_message_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, + bool schedule_message_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) { this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index); return this->schedule_batch_(); @@ -860,7 +833,7 @@ class APIConnection final : public APIServerConnectionBase { // Helper function to schedule a high priority message at the front of the batch // Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths - bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); + bool schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size); // Helper function to log client messages with name and peername void log_client_(int level, const LogString *message); @@ -871,6 +844,9 @@ class APIConnection final : public APIServerConnectionBase { this->on_fatal_error(); this->log_warning_(message, err); } + // Shared cold path for buffer allocation failures — noinline keeps the + // OOM handling out of the hot send paths + void __attribute__((noinline)) fatal_out_of_memory_(); }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h new file mode 100644 index 0000000000..08520249bf --- /dev/null +++ b/esphome/components/api/api_connection_buffer.h @@ -0,0 +1,74 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_API + +// Inline APIConnection members that need APIServer complete. Include this +// instead of api_connection.h when calling them. + +#include "api_connection.h" +#include "api_server.h" + +namespace esphome::api { + +inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size, + MessageEncodeFn encode_fn, const void *msg, + APIConnection *conn, uint32_t remaining_size) { +#ifdef HAS_PROTO_MESSAGE_DUMP + if (conn->flags_.log_only_mode) { + auto *proto_msg = static_cast(msg); + DumpBuffer dump_buf; + conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf)); + return 1; + } +#endif + const uint8_t footer_size = conn->helper_->frame_footer_size(); + + // First message uses max padding (already in buffer), subsequent use exact header size + size_t to_add; + if (conn->flags_.batch_first_message) { + conn->flags_.batch_first_message = false; + conn->batch_header_size_ = conn->helper_->frame_header_padding(); + to_add = calculated_size; + } else { + conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_); + to_add = calculated_size + conn->batch_header_size_ + footer_size; + } + + // Check if it fits (using actual header size, not max padding) + uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size; + if (total_calculated_size > remaining_size) + return 0; + + auto &shared_buf = conn->parent_->get_shared_buffer_ref(); + if (!shared_buf.resize(shared_buf.size() + to_add)) [[unlikely]] { + conn->fatal_out_of_memory_(); + return 0; + } + ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size}; + encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf)); + + return total_calculated_size; +} + +inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); } + +inline bool APIConnection::prepare_first_message_buffer(size_t header_padding, size_t total_size) { + auto &shared_buf = this->parent_->get_shared_buffer_ref(); + shared_buf.clear(); + // Reserve space for header padding + message + footer + // - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext) + // - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext) + // Reserve full size but only set initial size to header padding + // so message encoding starts at the correct position + return shared_buf.reserve_and_resize(total_size, header_padding); +} + +inline bool APIConnection::prepare_first_message_buffer(size_t payload_size) { + const uint8_t header_padding = this->helper_->frame_header_padding(); + const uint8_t footer_size = this->helper_->frame_footer_size(); + return this->prepare_first_message_buffer(header_padding, payload_size + header_padding + footer_size); +} + +} // namespace esphome::api +#endif diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 90353b6402..38da444a18 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"); } @@ -170,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin // Queue unsent data into overflow buffer if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { - HELPER_LOG("Overflow buffer full, dropping connection"); + HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; } diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index f98eca8076..ff8aa7834c 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1; // Maximum number of messages to batch in a single write operation -// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there) +// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there) static constexpr size_t MAX_MESSAGES_PER_BATCH = 34; // Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars) @@ -49,16 +49,16 @@ struct ReadPacketBuffer { }; // Packed message info structure to minimize memory usage -// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits. -// The noise wire format encodes types as 16-bit, but the high byte is always 0. -// If message types ever exceed 255, this and encrypt_noise_message_ must be updated. +// message_type matches the wire formats: noise carries a fixed 16-bit type +// field, plaintext a type varint. The proto codegen caps message IDs at 16383 +// so the plaintext type varint fits the 2 bytes budgeted in HEADER_PADDING. struct MessageInfo { uint16_t offset; // Offset in buffer where message starts uint16_t payload_size; // Size of the message payload - uint8_t message_type; // Message type (0-255) + uint16_t message_type; // Message type (0-16383) uint8_t header_size; // Actual header size used (avoids recomputation in write path) - MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr) + MessageInfo(uint16_t type, uint16_t off, uint16_t size, uint8_t hdr) : offset(off), payload_size(size), message_type(type), header_size(hdr) {} }; @@ -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); @@ -144,7 +149,7 @@ class APIFrameHelper { // holding data too long waiting for Nagle's timer causes buffer exhaustion // and dropped messages. // - // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) // // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) @@ -168,7 +173,7 @@ class APIFrameHelper { } // Write a single protobuf message - the hot path (87-100% of all writes). // Caller must ensure state is DATA before calling. - virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0; // Write multiple protobuf messages in a single batched operation. // Caller must ensure state is DATA and messages is not empty. // messages contains (message_type, offset, length) for each message in the buffer. @@ -182,15 +187,15 @@ class APIFrameHelper { // Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC // footer, plaintext has footer=0). If a protocol with a plaintext footer is ever // added, this should become a virtual method. - uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const { + uint8_t frame_header_size(uint16_t payload_size, uint16_t message_type) const { #if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) return this->frame_footer_size_ ? this->frame_header_padding_ - : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); + : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type)); #elif defined(USE_API_NOISE) return this->frame_header_padding_; #else // USE_API_PLAINTEXT only - return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); + return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type)); #endif } // Get the frame footer size required by this protocol @@ -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. @@ -301,7 +312,7 @@ class APIFrameHelper { // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. - // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. + // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more. #ifdef USE_ESP8266 static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 6dba64a7f8..138dbdddba 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -2,9 +2,9 @@ #ifdef USE_API #ifdef USE_API_NOISE #include "api_connection.h" // For ClientInfo struct +#include "esphome/components/noise/noise.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" -#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "proto.h" @@ -17,6 +17,14 @@ namespace esphome::api { +using noise::noise_err_to_logstr; + +// api_frame_helper.h keeps its own MAX_HANDSHAKE_SIZE because that header is +// also compiled in plaintext-only builds without the noise component; keep +// the two definitions from drifting apart. +static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE, + "api and noise component handshake size limits must match"); + static const char *const TAG = "api.noise"; #ifdef USE_ESP8266 static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; @@ -51,45 +59,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; #define LOG_PACKET_RECEIVED(buffer) ((void) 0) #endif -/// Convert a noise error code to a readable error -const LogString *noise_err_to_logstr(int err) { - if (err == NOISE_ERROR_NO_MEMORY) - return LOG_STR("NO_MEMORY"); - if (err == NOISE_ERROR_UNKNOWN_ID) - return LOG_STR("UNKNOWN_ID"); - if (err == NOISE_ERROR_UNKNOWN_NAME) - return LOG_STR("UNKNOWN_NAME"); - if (err == NOISE_ERROR_MAC_FAILURE) - return LOG_STR("MAC_FAILURE"); - if (err == NOISE_ERROR_NOT_APPLICABLE) - return LOG_STR("NOT_APPLICABLE"); - if (err == NOISE_ERROR_SYSTEM) - return LOG_STR("SYSTEM"); - if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) - return LOG_STR("REMOTE_KEY_REQUIRED"); - if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) - return LOG_STR("LOCAL_KEY_REQUIRED"); - if (err == NOISE_ERROR_PSK_REQUIRED) - return LOG_STR("PSK_REQUIRED"); - if (err == NOISE_ERROR_INVALID_LENGTH) - return LOG_STR("INVALID_LENGTH"); - if (err == NOISE_ERROR_INVALID_PARAM) - return LOG_STR("INVALID_PARAM"); - if (err == NOISE_ERROR_INVALID_STATE) - return LOG_STR("INVALID_STATE"); - if (err == NOISE_ERROR_INVALID_NONCE) - return LOG_STR("INVALID_NONCE"); - if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) - return LOG_STR("INVALID_PRIVATE_KEY"); - if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) - return LOG_STR("INVALID_PUBLIC_KEY"); - if (err == NOISE_ERROR_INVALID_FORMAT) - return LOG_STR("INVALID_FORMAT"); - if (err == NOISE_ERROR_INVALID_SIGNATURE) - return LOG_STR("INVALID_SIGNATURE"); - return LOG_STR("UNKNOWN"); -} - /// Initialize the frame helper, returns OK if successful. APIError APINoiseFrameHelper::init() { APIError err = init_common_(); @@ -99,7 +68,10 @@ APIError APINoiseFrameHelper::init() { // init prologue size_t old_size = prologue_.size(); - prologue_.resize(old_size + PROLOGUE_INIT_LEN); + if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } #ifdef USE_ESP8266 memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else @@ -109,6 +81,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 +137,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; } @@ -163,9 +166,9 @@ APIError APINoiseFrameHelper::loop() { */ APIError APINoiseFrameHelper::try_read_frame_() { // read header - if (rx_header_buf_len_ < 3) { + if (rx_header_buf_len_ < noise::FRAME_HEADER_SIZE) { // no header information yet - uint8_t to_read = 3 - rx_header_buf_len_; + uint8_t to_read = static_cast(noise::FRAME_HEADER_SIZE) - rx_header_buf_len_; ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read); APIError err = handle_socket_read_result_(received); if (err != APIError::OK) { @@ -177,7 +180,7 @@ APIError APINoiseFrameHelper::try_read_frame_() { return APIError::WOULD_BLOCK; } - if (rx_header_buf_[0] != 0x01) { + if (rx_header_buf_[0] != noise::FRAME_INDICATOR) { state_ = State::FAILED; HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]); return APIError::BAD_INDICATOR; @@ -202,7 +205,10 @@ APIError APINoiseFrameHelper::try_read_frame_() { // During handshake, rx_buf_.size() is used in prologue construction, so // the buffer must be exactly msg_size to avoid prologue mismatch.) uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0); - this->rx_buf_.resize(alloc_size); + if (!this->rx_buf_.resize(alloc_size)) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } if (rx_buf_len_ < msg_size) { // more data to read @@ -269,7 +275,10 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { // Resize for: existing prologue + 2 size bytes + frame data size_t old_size = this->prologue_.size(); size_t rx_size = this->rx_buf_.size(); - this->prologue_.resize(old_size + 2 + rx_size); + if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } this->prologue_[old_size] = (uint8_t) (rx_size >> 8); this->prologue_[old_size + 1] = (uint8_t) rx_size; if (rx_size > 0) { @@ -317,15 +326,15 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { return APIError::OK; } APIError APINoiseFrameHelper::state_action_handshake_() { - int action = noise_handshakestate_get_action(this->handshake_); - if (action == NOISE_ACTION_READ_MESSAGE) { + noise::NoiseResponderHandshake::Action action = this->handshake_.action(); + if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) { return this->state_action_handshake_read_(); - } else if (action == NOISE_ACTION_WRITE_MESSAGE) { + } else if (action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) { return this->state_action_handshake_write_(); } // bad state for action this->state_ = State::FAILED; - HELPER_LOG("Bad action for handshake: %d", action); + HELPER_LOG("Bad action for handshake: %d", (int) action); return APIError::HANDSHAKESTATE_BAD_STATE; } APIError APINoiseFrameHelper::state_action_handshake_read_() { @@ -337,20 +346,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() { if (this->rx_buf_.empty()) { this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; - } else if (this->rx_buf_[0] != 0x00) { + } else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) { HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]); this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte")); return APIError::BAD_HANDSHAKE_ERROR_BYTE; } - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); - int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr); + int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1); if (err != 0) { // Special handling for MAC failure - this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure") - : LOG_STR("Handshake error")); + this->send_explicit_handshake_reject_(noise::reject_reason_for(err)); return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"), APIError::HANDSHAKESTATE_READ_FAILED); } @@ -359,18 +364,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() { } APIError APINoiseFrameHelper::state_action_handshake_write_() { uint8_t buffer[65]; - NoiseBuffer mbuf; - noise_buffer_init(mbuf); - noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1); + size_t msg_len = 0; - int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr); + int err = this->handshake_.write_message(buffer + 1, sizeof(buffer) - 1, msg_len); APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"), APIError::HANDSHAKESTATE_WRITE_FAILED); if (aerr != APIError::OK) return aerr; - buffer[0] = 0x00; // success + buffer[0] = noise::HANDSHAKE_STATUS_OK; - aerr = this->write_frame_(buffer, mbuf.size + 1); + aerr = this->write_frame_(buffer, msg_len + 1); if (aerr != APIError::OK) return aerr; return this->check_handshake_finished_(); @@ -378,33 +381,22 @@ APIError APINoiseFrameHelper::state_action_handshake_write_() { void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) { // Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes uint8_t data[32]; - data[0] = 0x01; // failure - -#ifdef USE_STORE_LOG_STR_IN_FLASH - // On ESP8266 with flash strings, we need to use PROGMEM-aware functions - size_t reason_len = strlen_P(reinterpret_cast(reason)); - reason_len = std::min(reason_len, sizeof(data) - 1); - if (reason_len > 0) { - memcpy_P(data + 1, reinterpret_cast(reason), reason_len); - } -#else - // Normal memory access - const char *reason_str = LOG_STR_ARG(reason); - size_t reason_len = strlen(reason_str); - reason_len = std::min(reason_len, sizeof(data) - 1); - if (reason_len > 0) { - // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string - std::memcpy(data + 1, reason_str, reason_len); - } -#endif - - size_t data_size = reason_len + 1; + static_assert(sizeof(data) >= noise::MAC_FAILURE_PAYLOAD_SIZE, + "reject buffer must fit the MAC failure wire contract"); + size_t data_size = noise::format_reject_payload(data, sizeof(data), reason); // temporarily remove failed state auto orig_state = state_; state_ = State::EXPLICIT_REJECT; - write_frame_(data, data_size); - state_ = orig_state; + APIError aerr = write_frame_(data, data_size); + if (aerr != APIError::OK) { + // Best effort; the reject reason is a diagnosis aid, not a protocol step + ESP_LOGW(TAG, "Sending handshake reject failed: %d", (int) aerr); + } + if (state_ == State::EXPLICIT_REJECT) { + // write_frame_ may have moved the state to FAILED; keep that decision + state_ = orig_state; + } } APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { APIError aerr = this->check_data_state_(); @@ -459,14 +451,12 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { } // Encrypt a single noise message in place and return the encrypted frame length. // Returns APIError::OK on success. -APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, +APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type, uint16_t &encrypted_len_out) { - // Write noise header - buf_start[0] = 0x01; // indicator - // buf_start[1], buf_start[2] to be set after encryption + // The noise frame header is written after encryption, when the size is known // Write message header (to be encrypted) - constexpr uint8_t msg_offset = 3; + constexpr uint8_t msg_offset = noise::FRAME_HEADER_SIZE; buf_start[msg_offset] = static_cast(message_type >> 8); // type high byte buf_start[msg_offset + 1] = static_cast(message_type); // type low byte buf_start[msg_offset + 2] = static_cast(payload_size >> 8); // data_len high byte @@ -484,26 +474,27 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_ if (aerr != APIError::OK) return aerr; - // Fill in the encrypted size - buf_start[1] = static_cast(mbuf.size >> 8); - buf_start[2] = static_cast(mbuf.size); + // Fill in the frame header now that the encrypted size is known + noise::write_frame_header(buf_start, static_cast(mbuf.size)); - encrypted_len_out = static_cast(3 + mbuf.size); // indicator + size + encrypted data + encrypted_len_out = static_cast(noise::FRAME_HEADER_SIZE + mbuf.size); return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { #ifdef ESPHOME_DEBUG_API assert(this->state_ == State::DATA); #endif + APIBuffer *buf = buffer.get_buffer(); // Resize buffer to include footer space for Noise MAC - if (this->frame_footer_size_) - buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_); + if (this->frame_footer_size_ && !buf->resize(buf->size() + this->frame_footer_size_)) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } - uint16_t payload_size = - static_cast(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_); - uint8_t *buf_start = buffer.get_buffer()->data(); + uint16_t payload_size = static_cast(buf->size() - HEADER_PADDING - this->frame_footer_size_); + uint8_t *buf_start = buf->data(); uint16_t encrypted_len; APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len); if (aerr != APIError::OK) @@ -537,21 +528,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s } APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { - uint8_t header[3]; - header[0] = 0x01; // indicator - header[1] = (uint8_t) (len >> 8); - header[2] = (uint8_t) len; + uint8_t header[noise::FRAME_HEADER_SIZE]; + noise::write_frame_header(header, len); if (len == 0) { - return this->write_raw_buf_(header, 3); + return this->write_raw_buf_(header, noise::FRAME_HEADER_SIZE); } struct iovec iov[2]; iov[0].iov_base = header; - iov[0].iov_len = 3; + iov[0].iov_len = noise::FRAME_HEADER_SIZE; iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return this->write_raw_iov_(iov, 2, 3 + len); + return this->write_raw_iov_(iov, 2, noise::FRAME_HEADER_SIZE + len); } /** Initiate the data structures for the handshake. @@ -559,42 +548,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { * @return 0 on success, -1 on error (check errno) */ APIError APINoiseFrameHelper::init_handshake_() { - int err; - memset(&nid_, 0, sizeof(nid_)); - // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; - // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto)); - nid_.pattern_id = NOISE_PATTERN_NN; - nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY; - nid_.dh_id = NOISE_DH_CURVE25519; - nid_.prefix_id = NOISE_PREFIX_STANDARD; - nid_.hybrid_id = NOISE_DH_NONE; - nid_.hash_id = NOISE_HASH_SHA256; - nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0; - - err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER); - APIError aerr = - handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED); + int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size()); + APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED); if (aerr != APIError::OK) return aerr; - - const auto &psk = this->ctx_.get_psk(); - err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); - aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"), - APIError::HANDSHAKESTATE_SETUP_FAILED); - if (aerr != APIError::OK) - return aerr; - - err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size()); - aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED); - if (aerr != APIError::OK) - return aerr; - // set_prologue copies it into handshakestate, so we can get rid of it now + // init copies the prologue into the handshakestate, so we can get rid of it now prologue_.release(); - - err = noise_handshakestate_start(handshake_); - aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); - if (aerr != APIError::OK) - return aerr; return APIError::OK; } @@ -603,15 +562,17 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { assert(state_ == State::HANDSHAKE); #endif - int action = noise_handshakestate_get_action(handshake_); - if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE) + noise::NoiseResponderHandshake::Action action = this->handshake_.action(); + if (action == noise::NoiseResponderHandshake::Action::ACTION_READ || + action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) return APIError::OK; - if (action != NOISE_ACTION_SPLIT) { + if (action != noise::NoiseResponderHandshake::Action::ACTION_SPLIT) { state_ = State::FAILED; - HELPER_LOG("Bad action for handshake: %d", action); + HELPER_LOG("Bad action for handshake: %d", (int) action); return APIError::HANDSHAKESTATE_BAD_STATE; } - int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_); + // split() also frees the handshake state + int err = this->handshake_.split(send_cipher_, recv_cipher_); APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED); if (aerr != APIError::OK) @@ -620,17 +581,11 @@ APIError APINoiseFrameHelper::check_handshake_finished_() { this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_); HELPER_LOG("Handshake complete!"); - noise_handshakestate_free(handshake_); - handshake_ = nullptr; state_ = State::DATA; return APIError::OK; } APINoiseFrameHelper::~APINoiseFrameHelper() { - if (handshake_ != nullptr) { - noise_handshakestate_free(handshake_); - handshake_ = nullptr; - } if (send_cipher_ != nullptr) { noise_cipherstate_free(send_cipher_); send_cipher_ = nullptr; @@ -641,16 +596,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() { } } -extern "C" { -// declare how noise generates random bytes (here with a good HWRNG based on the RF system) -void noise_rand_bytes(void *output, size_t len) { - if (!esphome::random_bytes(reinterpret_cast(output), len)) { - ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting"); - arch_restart(); - } -} -} - } // namespace esphome::api #endif // USE_API_NOISE #endif // USE_API diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 0676eab78d..366751738e 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -3,7 +3,7 @@ #ifdef USE_API #ifdef USE_API_NOISE #include "noise/protocol.h" -#include "api_noise_context.h" +#include "esphome/components/noise/noise_handshake.h" namespace esphome::api { @@ -14,20 +14,28 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Pos 1-2: encrypted payload size (16-bit big-endian) // Pos 3-6: encrypted type (16-bit) + data_len (16-bit) // Pos 7+: actual payload data - static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len + static constexpr uint8_t HEADER_PADDING = noise::FRAME_HEADER_SIZE + 2 + 2; // frame header + type + data_len - APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx) + APINoiseFrameHelper(std::unique_ptr socket, noise::NoiseContext &ctx) : APIFrameHelper(std::move(socket)), ctx_(ctx) { frame_header_padding_ = HEADER_PADDING; } ~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_packet(uint16_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_(); @@ -36,7 +44,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_handshake_write_(); APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); - APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, + APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type, uint16_t &encrypted_len_out); APIError init_handshake_(); APIError check_handshake_finished_(); @@ -44,25 +52,22 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError handle_handshake_frame_error_(APIError aerr); APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err); - // Pointers first (4 bytes each) - NoiseHandshakeState *handshake_{nullptr}; + // Pointers first (4 bytes each; the handshake wrapper holds one pointer) + noise::NoiseResponderHandshake handshake_; NoiseCipherState *send_cipher_{nullptr}; NoiseCipherState *recv_cipher_{nullptr}; // Reference to noise context (4 bytes on 32-bit) - APINoiseContext &ctx_; + noise::NoiseContext &ctx_; // Buffer for noise handshake prologue (released after handshake) APIBuffer prologue_; - // NoiseProtocolId (size depends on implementation) - NoiseProtocolId nid_; - // Group small types together // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) // Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase - uint8_t rx_header_buf_[3]; + uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE]; uint8_t rx_header_buf_len_ = 0; // 4 bytes total, no padding }; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index fa611a6e33..d4e3354fa0 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -5,6 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "api_pb2.h" #include "proto.h" #include #include @@ -89,6 +90,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; @@ -160,7 +172,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() { // Reserve space for body (+ null terminator so protobuf StringRef fields // can be safely null-terminated in-place after decode) - this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR); + if (!this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR)) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } if (rx_buf_len_ < rx_header_parsed_len_) { // more data to read @@ -241,24 +256,21 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_ *p = static_cast(value); } -// Encode an 8-bit varint (1-2 bytes) using pre-computed length. -ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) { - if (varint_len == 2) { - *p++ = static_cast(value | 0x80); - *p = static_cast(value >> 7); - } else { - *p = value; - } -} +// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint +// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this +// bound, write_plaintext_header's header_offset would underflow for the first +// message in a batch and the header write would land outside the buffer. +static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING, + "HEADER_PADDING cannot fit the type varint of the largest message ID"); // Write plaintext header into pre-allocated padding before payload. // padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg, // actual header size for contiguous batch messages). // Returns the total header length (indicator + varints). ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size, - uint8_t message_type, uint8_t padding_size) { + uint16_t message_type, uint8_t padding_size) { uint8_t size_varint_len = ProtoSize::varint16(payload_size); - uint8_t type_varint_len = ProtoSize::varint8(message_type); + uint8_t type_varint_len = ProtoSize::varint16(message_type); uint8_t total_header_len = 1 + size_varint_len + type_varint_len; // The header is right-justified within the padding so it sits immediately before payload. @@ -281,12 +293,12 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_ // Encode varints directly into buffer using pre-computed lengths encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1); - encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len); + encode_varint_16(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len); return total_header_len; } -APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { #ifdef ESPHOME_DEBUG_API assert(this->state_ == State::DATA); #endif diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index 8314754715..00e7c7b1bc 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -10,7 +10,8 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { // Plaintext header structure (worst case): // Pos 0: indicator (0x00) // Pos 1-3: payload size varint (up to 3 bytes) - // Pos 4-5: message type varint (up to 2 bytes) + // Pos 4-5: message type varint (up to 2 bytes; covers message IDs up to + // 16383, enforced by the proto codegen) // Pos 6+: actual payload data static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint @@ -21,8 +22,17 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; + APIError write_protobuf_packet(uint16_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 deleted file mode 100644 index b5f7016689..0000000000 --- a/esphome/components/api/api_noise_context.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once -#include -#include -#include "esphome/core/defines.h" - -namespace esphome::api { - -#ifdef USE_API_NOISE -using psk_t = std::array; - -class APINoiseContext { - public: - 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; - } - const psk_t &get_psk() const { return this->psk_; } - bool has_psk() const { return this->has_psk_; } - - protected: - psk_t psk_{}; - bool has_psk_{false}; -}; -#endif // USE_API_NOISE - -} // namespace esphome::api diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index ac9c4e59cc..66295b3d53 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -116,4 +116,10 @@ extend google.protobuf.FieldOptions { // the per-byte loop when the upper bits are non-zero (the common case // for real MAC addresses, since OUIs occupy the top 24 bits). optional bool mac_address = 50019 [default=false]; + + // track_presence: Track whether this message-typed field was present on the wire. + // Generates a `bool has_{false};` member on the decoding side that is set + // to true when the field arrives, so an all-default submessage can be told apart + // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). + optional bool track_presence = 50020 [default=false]; } diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index e242d4553e..48d8fe18ba 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,6 +1,7 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include +#include namespace esphome::api { @@ -12,6 +13,22 @@ APIOverflowBuffer::~APIOverflowBuffer() { } ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { + // socket->write() can re-enter this function: a log message emitted from an + // lwip callback during the write goes out over the API and lands back in the + // frame helper's write/drain path. If a nested drain ran here it would send + // and free the entry the outer drain is still holding, causing a double free. + // Report "no progress" instead; the outer drain keeps draining, and the + // nested send is enqueued behind the existing backlog. + if (this->draining_) + return 0; + + // RAII so the flag is cleared on every return path + struct DrainGuard { + explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; } + ~DrainGuard() { this->flag_ = false; } + bool &flag_; + } guard(this->draining_); + while (this->count_ > 0) { Entry *front = this->queue_[this->head_]; @@ -29,11 +46,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { return sent; } - // Entry fully sent — free it and advance - Entry::destroy(front); + // Entry fully sent — unlink it before freeing so a freed pointer is never + // reachable from the queue this->queue_[this->head_] = nullptr; this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; this->count_--; + Entry::destroy(front); } return 0; // All drained @@ -44,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_ return false; uint16_t buffer_size = total_len - skip; + // nothrow: a failed allocation returns nullptr so the connection is dropped + // cleanly instead of plain new's crash or abort on OOM // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0}; - this->queue_[this->tail_] = entry; + auto *data = new (std::nothrow) uint8_t[buffer_size]; + if (data == nullptr) + return false; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; + if (entry == nullptr) { + delete[] data; + return false; + } uint16_t to_skip = skip; uint16_t write_pos = 0; @@ -63,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_ } } + // Publish only after the copy completes so a half-built entry is never reachable + this->queue_[this->tail_] = entry; this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 19aae680f0..03a334b281 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -61,7 +61,7 @@ class APIOverflowBuffer { /// Enqueue unsent IOV data into the backlog. /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full (caller should fail the connection). + /// Returns false if the queue is full or allocation fails (caller should fail the connection). bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); protected: @@ -69,6 +69,10 @@ class APIOverflowBuffer { uint8_t head_{0}; uint8_t tail_{0}; uint8_t count_{0}; + // Guards against re-entrant drains: socket->write() can re-enter the API + // send path (e.g. a log message emitted from an lwip callback), and a nested + // drain would free the entry the outer drain is still holding. + bool draining_{false}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c711ef167c..f56d791b67 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(); @@ -82,12 +102,14 @@ uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PAR uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); return pos; } uint32_t SerialProxyInfo::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->name.size()); size += this->port_type ? 2 : 0; + size += ProtoSize::calc_uint32(1, this->configured_line_states); return size; } #endif @@ -150,6 +172,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 +237,85 @@ 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; +} +#ifdef USE_BLUETOOTH_PROXY +uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + return pos; +} +uint32_t BluetoothProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += 2 + this->mac_address.size(); + return size; +} +#endif +#ifdef USE_VOICE_ASSISTANT +uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + return pos; +} +uint32_t VoiceAssistantCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + return size; +} +#endif +#ifdef USE_ZWAVE_PROXY +uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + return pos; +} +uint32_t ZWaveProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, this->home_id); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_BLUETOOTH_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); +#endif +#ifdef USE_VOICE_ASSISTANT + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); +#endif +#ifdef USE_ZWAVE_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + } +#endif + return pos; +} +uint32_t DeviceCapabilitiesResponse::calculate_size() const { + uint32_t size = 0; +#ifdef USE_BLUETOOTH_PROXY + size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); +#endif +#ifdef USE_VOICE_ASSISTANT + size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); +#endif +#ifdef USE_ZWAVE_PROXY + size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + size += ProtoSize::calc_message_force(1, it.calculate_size()); + } #endif return size; } @@ -1147,12 +1251,9 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value } bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: { - this->timezone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } case 3: value.decode_to_message(this->parsed_timezone); + this->has_parsed_timezone = true; break; default: return false; @@ -2222,7 +2323,6 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ #endif ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause); for (auto &it : this->supported_formats) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); } @@ -2242,7 +2342,6 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->supports_pause); if (!this->supported_formats.empty()) { for (const auto &it : this->supported_formats) { size += ProtoSize::calc_message_force(1, it.calculate_size()); @@ -2380,6 +2479,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: @@ -2756,6 +2857,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::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->state)); @@ -3839,6 +3942,18 @@ uint32_t ZWaveProxyRequest::calculate_size() const { size += ProtoSize::calc_length(1, this->data_len); return size; } +uint8_t *ZWaveProxyRequestResponse::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->type)); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); + return pos; +} +uint32_t ZWaveProxyRequestResponse::calculate_size() const { + uint32_t size = 0; + size += this->type ? 2 : 0; + size += this->status ? 2 : 0; + return size; +} #endif #ifdef USE_INFRARED uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -4081,12 +4196,14 @@ uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); return pos; } uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_uint32(1, this->instance); size += ProtoSize::calc_uint32(1, this->line_states); + size += this->status ? 2 : 0; return size; } bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -4119,7 +4236,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { return size; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7e926ee0d4..bed28d2956 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -9,8 +9,16 @@ namespace esphome::api { +// Upper bound on message IDs, enforced by the code generator: the plaintext +// frame header budgets 2 varint bytes for the type (HEADER_PADDING). +static constexpr uint16_t MAX_MESSAGE_TYPE = 16383; + 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, @@ -221,7 +229,7 @@ enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1, }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0, BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1, @@ -231,6 +239,8 @@ enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5, BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6, }; +#endif +#ifdef USE_BLUETOOTH_PROXY enum BluetoothScannerState : uint32_t { BLUETOOTH_SCANNER_STATE_IDLE = 0, BLUETOOTH_SCANNER_STATE_STARTING = 1, @@ -328,6 +338,11 @@ enum ZWaveProxyRequestType : uint32_t { ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2, }; +enum ZWaveProxyStatus : uint32_t { + ZWAVE_PROXY_STATUS_OK = 0, + ZWAVE_PROXY_STATUS_IN_USE = 1, + ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2, +}; #endif #ifdef USE_SERIAL_PROXY enum SerialProxyParity : uint32_t { @@ -339,6 +354,8 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0, SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1, SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, + SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3, + SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, @@ -346,6 +363,8 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_ERROR = 2, SERIAL_PROXY_STATUS_TIMEOUT = 3, SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4, + SERIAL_PROXY_STATUS_PORT_IN_USE = 5, + SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6, }; #endif @@ -392,7 +411,7 @@ class CommandProtoMessage : public ProtoDecodableMessage { }; class HelloRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 1; + static constexpr uint16_t MESSAGE_TYPE = 1; static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_request"); } @@ -410,7 +429,7 @@ class HelloRequest final : public ProtoDecodableMessage { }; class HelloResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 2; + static constexpr uint16_t MESSAGE_TYPE = 2; static constexpr uint8_t ESTIMATED_SIZE = 26; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_response"); } @@ -427,22 +446,26 @@ 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 uint16_t MESSAGE_TYPE = 5; + 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: - static constexpr uint8_t MESSAGE_TYPE = 6; + static constexpr uint16_t MESSAGE_TYPE = 6; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_response"); } @@ -455,7 +478,7 @@ class DisconnectResponse final : public ProtoMessage { }; class PingRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 7; + static constexpr uint16_t MESSAGE_TYPE = 7; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("ping_request"); } @@ -468,7 +491,7 @@ class PingRequest final : public ProtoMessage { }; class PingResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 8; + static constexpr uint16_t MESSAGE_TYPE = 8; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("ping_response"); } @@ -513,6 +536,7 @@ class SerialProxyInfo final : public ProtoMessage { public: StringRef name{}; enums::SerialProxyPortType port_type{}; + uint32_t configured_line_states{0}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -524,8 +548,8 @@ class SerialProxyInfo final : public ProtoMessage { #endif class DeviceInfoResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 309; + static constexpr uint16_t MESSAGE_TYPE = 10; + 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 @@ -578,6 +602,77 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_ZWAVE_PROXY uint32_t zwave_home_id{0}; #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; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#ifdef USE_BLUETOOTH_PROXY +class BluetoothProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + StringRef mac_address{}; + 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: +}; +#endif +#ifdef USE_VOICE_ASSISTANT +class VoiceAssistantCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_ZWAVE_PROXY +class ZWaveProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint32_t home_id{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +class DeviceCapabilitiesResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 102; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } +#endif +#ifdef USE_BLUETOOTH_PROXY + BluetoothProxyCapabilities bluetooth_proxy{}; +#endif +#ifdef USE_VOICE_ASSISTANT + VoiceAssistantCapabilities voice_assistant{}; +#endif +#ifdef USE_ZWAVE_PROXY + ZWaveProxyCapabilities zwave_proxy{}; +#endif #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; #endif @@ -591,7 +686,7 @@ class DeviceInfoResponse final : public ProtoMessage { }; class ListEntitiesDoneResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 19; + static constexpr uint16_t MESSAGE_TYPE = 19; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_done_response"); } @@ -605,7 +700,7 @@ class ListEntitiesDoneResponse final : public ProtoMessage { #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 12; + static constexpr uint16_t MESSAGE_TYPE = 12; static constexpr uint8_t ESTIMATED_SIZE = 51; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_binary_sensor_response"); } @@ -622,7 +717,7 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { }; class BinarySensorStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 21; + static constexpr uint16_t MESSAGE_TYPE = 21; static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("binary_sensor_state_response"); } @@ -641,7 +736,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { #ifdef USE_COVER class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 13; + static constexpr uint16_t MESSAGE_TYPE = 13; static constexpr uint8_t ESTIMATED_SIZE = 57; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); } @@ -661,7 +756,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { }; class CoverStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 22; + static constexpr uint16_t MESSAGE_TYPE = 22; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("cover_state_response"); } @@ -679,7 +774,7 @@ class CoverStateResponse final : public StateResponseProtoMessage { }; class CoverCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 30; + static constexpr uint16_t MESSAGE_TYPE = 30; static constexpr uint8_t ESTIMATED_SIZE = 25; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("cover_command_request"); } @@ -701,7 +796,7 @@ class CoverCommandRequest final : public CommandProtoMessage { #ifdef USE_FAN class ListEntitiesFanResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 14; + static constexpr uint16_t MESSAGE_TYPE = 14; static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_fan_response"); } @@ -721,7 +816,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { }; class FanStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 23; + static constexpr uint16_t MESSAGE_TYPE = 23; static constexpr uint8_t ESTIMATED_SIZE = 28; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("fan_state_response"); } @@ -741,7 +836,7 @@ class FanStateResponse final : public StateResponseProtoMessage { }; class FanCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 31; + static constexpr uint16_t MESSAGE_TYPE = 31; static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("fan_command_request"); } @@ -769,7 +864,7 @@ class FanCommandRequest final : public CommandProtoMessage { #ifdef USE_LIGHT class ListEntitiesLightResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 15; + static constexpr uint16_t MESSAGE_TYPE = 15; static constexpr uint8_t ESTIMATED_SIZE = 73; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_light_response"); } @@ -788,7 +883,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { }; class LightStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 24; + static constexpr uint16_t MESSAGE_TYPE = 24; static constexpr uint8_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("light_state_response"); } @@ -815,7 +910,7 @@ class LightStateResponse final : public StateResponseProtoMessage { }; class LightCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 32; + static constexpr uint16_t MESSAGE_TYPE = 32; static constexpr uint8_t ESTIMATED_SIZE = 112; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("light_command_request"); } @@ -859,7 +954,7 @@ class LightCommandRequest final : public CommandProtoMessage { #ifdef USE_SENSOR class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 16; + static constexpr uint16_t MESSAGE_TYPE = 16; static constexpr uint8_t ESTIMATED_SIZE = 66; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_sensor_response"); } @@ -879,7 +974,7 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { }; class SensorStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 25; + static constexpr uint16_t MESSAGE_TYPE = 25; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("sensor_state_response"); } @@ -898,7 +993,7 @@ class SensorStateResponse final : public StateResponseProtoMessage { #ifdef USE_SWITCH class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 17; + static constexpr uint16_t MESSAGE_TYPE = 17; static constexpr uint8_t ESTIMATED_SIZE = 51; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_switch_response"); } @@ -915,7 +1010,7 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { }; class SwitchStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 26; + static constexpr uint16_t MESSAGE_TYPE = 26; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_state_response"); } @@ -931,7 +1026,7 @@ class SwitchStateResponse final : public StateResponseProtoMessage { }; class SwitchCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 33; + static constexpr uint16_t MESSAGE_TYPE = 33; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_command_request"); } @@ -949,7 +1044,7 @@ class SwitchCommandRequest final : public CommandProtoMessage { #ifdef USE_TEXT_SENSOR class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 18; + static constexpr uint16_t MESSAGE_TYPE = 18; static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } @@ -965,7 +1060,7 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { }; class TextSensorStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 27; + static constexpr uint16_t MESSAGE_TYPE = 27; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_sensor_state_response"); } @@ -983,7 +1078,7 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #endif class SubscribeLogsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 28; + static constexpr uint16_t MESSAGE_TYPE = 28; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_logs_request"); } @@ -999,7 +1094,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { }; class SubscribeLogsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 29; + static constexpr uint16_t MESSAGE_TYPE = 29; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_logs_response"); } @@ -1022,7 +1117,7 @@ class SubscribeLogsResponse final : public ProtoMessage { #ifdef USE_API_NOISE class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 124; + static constexpr uint16_t MESSAGE_TYPE = 124; static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_request"); } @@ -1038,7 +1133,7 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { }; class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 125; + static constexpr uint16_t MESSAGE_TYPE = 125; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); } @@ -1068,7 +1163,7 @@ class HomeassistantServiceMap final : public ProtoMessage { }; class HomeassistantActionRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 35; + static constexpr uint16_t MESSAGE_TYPE = 35; static constexpr uint8_t ESTIMATED_SIZE = 128; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } @@ -1099,7 +1194,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES class HomeassistantActionResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 130; + static constexpr uint16_t MESSAGE_TYPE = 130; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_response"); } @@ -1123,7 +1218,7 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { #ifdef USE_API_HOMEASSISTANT_STATES class SubscribeHomeAssistantStateResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 39; + static constexpr uint16_t MESSAGE_TYPE = 39; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_home_assistant_state_response"); } @@ -1141,7 +1236,7 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { }; class HomeAssistantStateResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 40; + static constexpr uint16_t MESSAGE_TYPE = 40; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("home_assistant_state_response"); } @@ -1159,7 +1254,7 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { #endif class GetTimeRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 36; + static constexpr uint16_t MESSAGE_TYPE = 36; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_time_request"); } @@ -1201,14 +1296,14 @@ class ParsedTimezone final : public ProtoDecodableMessage { }; class GetTimeResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 37; - static constexpr uint8_t ESTIMATED_SIZE = 31; + static constexpr uint16_t MESSAGE_TYPE = 37; + static constexpr uint8_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_time_response"); } #endif uint32_t epoch_seconds{0}; - StringRef timezone{}; ParsedTimezone parsed_timezone{}; + bool has_parsed_timezone{false}; #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -1232,7 +1327,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage { }; class ListEntitiesServicesResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 41; + static constexpr uint16_t MESSAGE_TYPE = 41; static constexpr uint8_t ESTIMATED_SIZE = 50; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } @@ -1272,7 +1367,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 42; + static constexpr uint16_t MESSAGE_TYPE = 42; static constexpr uint8_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("execute_service_request"); } @@ -1299,7 +1394,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES class ExecuteServiceResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 131; + static constexpr uint16_t MESSAGE_TYPE = 131; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("execute_service_response"); } @@ -1323,7 +1418,7 @@ class ExecuteServiceResponse final : public ProtoMessage { #ifdef USE_CAMERA class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 43; + static constexpr uint16_t MESSAGE_TYPE = 43; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); } @@ -1338,7 +1433,7 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { }; class CameraImageResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 44; + static constexpr uint16_t MESSAGE_TYPE = 44; static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("camera_image_response"); } @@ -1360,7 +1455,7 @@ class CameraImageResponse final : public StateResponseProtoMessage { }; class CameraImageRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 45; + static constexpr uint16_t MESSAGE_TYPE = 45; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("camera_image_request"); } @@ -1378,7 +1473,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { #ifdef USE_CLIMATE class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 46; + static constexpr uint16_t MESSAGE_TYPE = 46; static constexpr uint8_t ESTIMATED_SIZE = 153; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); } @@ -1412,7 +1507,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { }; class ClimateStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 47; + static constexpr uint16_t MESSAGE_TYPE = 47; static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_state_response"); } @@ -1440,7 +1535,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { }; class ClimateCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 48; + static constexpr uint16_t MESSAGE_TYPE = 48; static constexpr uint8_t ESTIMATED_SIZE = 84; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_command_request"); } @@ -1478,7 +1573,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { #ifdef USE_WATER_HEATER class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 132; + static constexpr uint16_t MESSAGE_TYPE = 132; static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); } @@ -1499,7 +1594,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { }; class WaterHeaterStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 133; + static constexpr uint16_t MESSAGE_TYPE = 133; static constexpr uint8_t ESTIMATED_SIZE = 35; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); } @@ -1520,7 +1615,7 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { }; class WaterHeaterCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 134; + static constexpr uint16_t MESSAGE_TYPE = 134; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_command_request"); } @@ -1543,7 +1638,7 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { #ifdef USE_NUMBER class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 49; + static constexpr uint16_t MESSAGE_TYPE = 49; static constexpr uint8_t ESTIMATED_SIZE = 75; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_number_response"); } @@ -1564,7 +1659,7 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { }; class NumberStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 50; + static constexpr uint16_t MESSAGE_TYPE = 50; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("number_state_response"); } @@ -1581,7 +1676,7 @@ class NumberStateResponse final : public StateResponseProtoMessage { }; class NumberCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 51; + static constexpr uint16_t MESSAGE_TYPE = 51; static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("number_command_request"); } @@ -1599,7 +1694,7 @@ class NumberCommandRequest final : public CommandProtoMessage { #ifdef USE_SELECT class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 52; + static constexpr uint16_t MESSAGE_TYPE = 52; static constexpr uint8_t ESTIMATED_SIZE = 58; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } @@ -1615,7 +1710,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { }; class SelectStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 53; + static constexpr uint16_t MESSAGE_TYPE = 53; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("select_state_response"); } @@ -1632,7 +1727,7 @@ class SelectStateResponse final : public StateResponseProtoMessage { }; class SelectCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 54; + static constexpr uint16_t MESSAGE_TYPE = 54; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("select_command_request"); } @@ -1651,7 +1746,7 @@ class SelectCommandRequest final : public CommandProtoMessage { #ifdef USE_SIREN class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 55; + static constexpr uint16_t MESSAGE_TYPE = 55; static constexpr uint8_t ESTIMATED_SIZE = 62; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_siren_response"); } @@ -1669,7 +1764,7 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { }; class SirenStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 56; + static constexpr uint16_t MESSAGE_TYPE = 56; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("siren_state_response"); } @@ -1685,7 +1780,7 @@ class SirenStateResponse final : public StateResponseProtoMessage { }; class SirenCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 57; + static constexpr uint16_t MESSAGE_TYPE = 57; static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("siren_command_request"); } @@ -1711,7 +1806,7 @@ class SirenCommandRequest final : public CommandProtoMessage { #ifdef USE_LOCK class ListEntitiesLockResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 58; + static constexpr uint16_t MESSAGE_TYPE = 58; static constexpr uint8_t ESTIMATED_SIZE = 55; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_lock_response"); } @@ -1730,7 +1825,7 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { }; class LockStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 59; + static constexpr uint16_t MESSAGE_TYPE = 59; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("lock_state_response"); } @@ -1746,7 +1841,7 @@ class LockStateResponse final : public StateResponseProtoMessage { }; class LockCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 60; + static constexpr uint16_t MESSAGE_TYPE = 60; static constexpr uint8_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("lock_command_request"); } @@ -1767,7 +1862,7 @@ class LockCommandRequest final : public CommandProtoMessage { #ifdef USE_BUTTON class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 61; + static constexpr uint16_t MESSAGE_TYPE = 61; static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } @@ -1783,7 +1878,7 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { }; class ButtonCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 62; + static constexpr uint16_t MESSAGE_TYPE = 62; static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("button_command_request"); } @@ -1815,12 +1910,11 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { }; class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 63; - static constexpr uint8_t ESTIMATED_SIZE = 80; + static constexpr uint16_t MESSAGE_TYPE = 63; + static constexpr uint8_t ESTIMATED_SIZE = 78; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_media_player_response"); } #endif - bool supports_pause{false}; std::vector supported_formats{}; uint32_t feature_flags{0}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; @@ -1833,7 +1927,7 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { }; class MediaPlayerStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 64; + static constexpr uint16_t MESSAGE_TYPE = 64; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("media_player_state_response"); } @@ -1851,7 +1945,7 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage { }; class MediaPlayerCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 65; + static constexpr uint16_t MESSAGE_TYPE = 65; static constexpr uint8_t ESTIMATED_SIZE = 35; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("media_player_command_request"); } @@ -1877,7 +1971,7 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { #ifdef USE_BLUETOOTH_PROXY class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 66; + static constexpr uint16_t MESSAGE_TYPE = 66; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_bluetooth_le_advertisements_request"); } @@ -1905,7 +1999,7 @@ class BluetoothLERawAdvertisement final : public ProtoMessage { }; class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 93; + static constexpr uint16_t MESSAGE_TYPE = 93; static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_le_raw_advertisements_response"); } @@ -1920,9 +2014,11 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 68; + static constexpr uint16_t MESSAGE_TYPE = 68; static constexpr uint8_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_request"); } @@ -1940,7 +2036,7 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 69; + static constexpr uint16_t MESSAGE_TYPE = 69; static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_connection_response"); } @@ -1959,7 +2055,7 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage { }; class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 70; + static constexpr uint16_t MESSAGE_TYPE = 70; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_request"); } @@ -2016,7 +2112,7 @@ class BluetoothGATTService final : public ProtoMessage { }; class BluetoothGATTGetServicesResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 71; + static constexpr uint16_t MESSAGE_TYPE = 71; static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_response"); } @@ -2033,7 +2129,7 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage { }; class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 72; + static constexpr uint16_t MESSAGE_TYPE = 72; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); } @@ -2049,7 +2145,7 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { }; class BluetoothGATTReadRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 73; + static constexpr uint16_t MESSAGE_TYPE = 73; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_request"); } @@ -2065,7 +2161,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { }; class BluetoothGATTReadResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 74; + static constexpr uint16_t MESSAGE_TYPE = 74; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_response"); } @@ -2088,7 +2184,7 @@ class BluetoothGATTReadResponse final : public ProtoMessage { }; class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 75; + static constexpr uint16_t MESSAGE_TYPE = 75; static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_request"); } @@ -2108,7 +2204,7 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 76; + static constexpr uint16_t MESSAGE_TYPE = 76; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_descriptor_request"); } @@ -2124,7 +2220,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 77; + static constexpr uint16_t MESSAGE_TYPE = 77; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_descriptor_request"); } @@ -2143,7 +2239,7 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 78; + static constexpr uint16_t MESSAGE_TYPE = 78; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_request"); } @@ -2160,7 +2256,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 79; + static constexpr uint16_t MESSAGE_TYPE = 79; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_data_response"); } @@ -2183,7 +2279,7 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { }; class BluetoothConnectionsFreeResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 81; + static constexpr uint16_t MESSAGE_TYPE = 81; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_connections_free_response"); } @@ -2201,7 +2297,7 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage { }; class BluetoothGATTErrorResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 82; + static constexpr uint16_t MESSAGE_TYPE = 82; static constexpr uint8_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_error_response"); } @@ -2219,7 +2315,7 @@ class BluetoothGATTErrorResponse final : public ProtoMessage { }; class BluetoothGATTWriteResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 83; + static constexpr uint16_t MESSAGE_TYPE = 83; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_response"); } @@ -2236,7 +2332,7 @@ class BluetoothGATTWriteResponse final : public ProtoMessage { }; class BluetoothGATTNotifyResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 84; + static constexpr uint16_t MESSAGE_TYPE = 84; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_response"); } @@ -2253,7 +2349,7 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage { }; class BluetoothDevicePairingResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 85; + static constexpr uint16_t MESSAGE_TYPE = 85; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_pairing_response"); } @@ -2271,7 +2367,7 @@ class BluetoothDevicePairingResponse final : public ProtoMessage { }; class BluetoothDeviceUnpairingResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 86; + static constexpr uint16_t MESSAGE_TYPE = 86; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_unpairing_response"); } @@ -2289,7 +2385,7 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { }; class BluetoothDeviceClearCacheResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 88; + static constexpr uint16_t MESSAGE_TYPE = 88; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_clear_cache_response"); } @@ -2305,9 +2401,11 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY class BluetoothScannerStateResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 126; + static constexpr uint16_t MESSAGE_TYPE = 126; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_state_response"); } @@ -2325,7 +2423,7 @@ class BluetoothScannerStateResponse final : public ProtoMessage { }; class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 127; + static constexpr uint16_t MESSAGE_TYPE = 127; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_set_mode_request"); } @@ -2342,7 +2440,7 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { #ifdef USE_VOICE_ASSISTANT class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 89; + static constexpr uint16_t MESSAGE_TYPE = 89; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_voice_assistant_request"); } @@ -2371,7 +2469,7 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { }; class VoiceAssistantRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 90; + static constexpr uint16_t MESSAGE_TYPE = 90; static constexpr uint8_t ESTIMATED_SIZE = 41; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); } @@ -2391,7 +2489,7 @@ class VoiceAssistantRequest final : public ProtoMessage { }; class VoiceAssistantResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 91; + static constexpr uint16_t MESSAGE_TYPE = 91; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_response"); } @@ -2418,7 +2516,7 @@ class VoiceAssistantEventData final : public ProtoDecodableMessage { }; class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 92; + static constexpr uint16_t MESSAGE_TYPE = 92; static constexpr uint8_t ESTIMATED_SIZE = 36; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_event_response"); } @@ -2435,7 +2533,7 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 106; + static constexpr uint16_t MESSAGE_TYPE = 106; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); } @@ -2457,7 +2555,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 115; + static constexpr uint16_t MESSAGE_TYPE = 115; static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_timer_event_response"); } @@ -2478,7 +2576,7 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 119; + static constexpr uint16_t MESSAGE_TYPE = 119; static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_request"); } @@ -2497,7 +2595,7 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 120; + static constexpr uint16_t MESSAGE_TYPE = 120; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); } @@ -2543,7 +2641,7 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 121; + static constexpr uint16_t MESSAGE_TYPE = 121; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_request"); } @@ -2558,7 +2656,7 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 122; + static constexpr uint16_t MESSAGE_TYPE = 122; static constexpr uint8_t ESTIMATED_SIZE = 56; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_response"); } @@ -2576,7 +2674,7 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage { }; class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 123; + static constexpr uint16_t MESSAGE_TYPE = 123; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_set_configuration"); } @@ -2593,7 +2691,7 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { #ifdef USE_ALARM_CONTROL_PANEL class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 94; + static constexpr uint16_t MESSAGE_TYPE = 94; static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_alarm_control_panel_response"); } @@ -2611,7 +2709,7 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess }; class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 95; + static constexpr uint16_t MESSAGE_TYPE = 95; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); } @@ -2627,7 +2725,7 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { }; class AlarmControlPanelCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 96; + static constexpr uint16_t MESSAGE_TYPE = 96; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("alarm_control_panel_command_request"); } @@ -2647,7 +2745,7 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { #ifdef USE_TEXT class ListEntitiesTextResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 97; + static constexpr uint16_t MESSAGE_TYPE = 97; static constexpr uint8_t ESTIMATED_SIZE = 59; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_response"); } @@ -2666,7 +2764,7 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { }; class TextStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 98; + static constexpr uint16_t MESSAGE_TYPE = 98; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_state_response"); } @@ -2683,7 +2781,7 @@ class TextStateResponse final : public StateResponseProtoMessage { }; class TextCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 99; + static constexpr uint16_t MESSAGE_TYPE = 99; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_command_request"); } @@ -2702,7 +2800,7 @@ class TextCommandRequest final : public CommandProtoMessage { #ifdef USE_DATETIME_DATE class ListEntitiesDateResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 100; + static constexpr uint16_t MESSAGE_TYPE = 100; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); } @@ -2717,7 +2815,7 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage { }; class DateStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 101; + static constexpr uint16_t MESSAGE_TYPE = 101; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_state_response"); } @@ -2736,7 +2834,7 @@ class DateStateResponse final : public StateResponseProtoMessage { }; class DateCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 102; + static constexpr uint16_t MESSAGE_TYPE = 102; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_command_request"); } @@ -2756,7 +2854,7 @@ class DateCommandRequest final : public CommandProtoMessage { #ifdef USE_DATETIME_TIME class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 103; + static constexpr uint16_t MESSAGE_TYPE = 103; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); } @@ -2771,7 +2869,7 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { }; class TimeStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 104; + static constexpr uint16_t MESSAGE_TYPE = 104; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("time_state_response"); } @@ -2790,7 +2888,7 @@ class TimeStateResponse final : public StateResponseProtoMessage { }; class TimeCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 105; + static constexpr uint16_t MESSAGE_TYPE = 105; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("time_command_request"); } @@ -2810,7 +2908,7 @@ class TimeCommandRequest final : public CommandProtoMessage { #ifdef USE_EVENT class ListEntitiesEventResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 107; + static constexpr uint16_t MESSAGE_TYPE = 107; static constexpr uint8_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } @@ -2827,7 +2925,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { }; class EventResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 108; + static constexpr uint16_t MESSAGE_TYPE = 108; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("event_response"); } @@ -2845,7 +2943,7 @@ class EventResponse final : public StateResponseProtoMessage { #ifdef USE_VALVE class ListEntitiesValveResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 109; + static constexpr uint16_t MESSAGE_TYPE = 109; static constexpr uint8_t ESTIMATED_SIZE = 55; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_valve_response"); } @@ -2864,7 +2962,7 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { }; class ValveStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 110; + static constexpr uint16_t MESSAGE_TYPE = 110; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("valve_state_response"); } @@ -2881,7 +2979,7 @@ class ValveStateResponse final : public StateResponseProtoMessage { }; class ValveCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 111; + static constexpr uint16_t MESSAGE_TYPE = 111; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("valve_command_request"); } @@ -2901,7 +2999,7 @@ class ValveCommandRequest final : public CommandProtoMessage { #ifdef USE_DATETIME_DATETIME class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 112; + static constexpr uint16_t MESSAGE_TYPE = 112; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); } @@ -2916,7 +3014,7 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { }; class DateTimeStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 113; + static constexpr uint16_t MESSAGE_TYPE = 113; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_time_state_response"); } @@ -2933,7 +3031,7 @@ class DateTimeStateResponse final : public StateResponseProtoMessage { }; class DateTimeCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 114; + static constexpr uint16_t MESSAGE_TYPE = 114; static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_time_command_request"); } @@ -2951,7 +3049,7 @@ class DateTimeCommandRequest final : public CommandProtoMessage { #ifdef USE_UPDATE class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 116; + static constexpr uint16_t MESSAGE_TYPE = 116; static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } @@ -2967,7 +3065,7 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { }; class UpdateStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 117; + static constexpr uint16_t MESSAGE_TYPE = 117; static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("update_state_response"); } @@ -2991,7 +3089,7 @@ class UpdateStateResponse final : public StateResponseProtoMessage { }; class UpdateCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 118; + static constexpr uint16_t MESSAGE_TYPE = 118; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("update_command_request"); } @@ -3009,7 +3107,7 @@ class UpdateCommandRequest final : public CommandProtoMessage { #ifdef USE_ZWAVE_PROXY class ZWaveProxyFrame final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 128; + static constexpr uint16_t MESSAGE_TYPE = 128; static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("z_wave_proxy_frame"); } @@ -3027,7 +3125,7 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { }; class ZWaveProxyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 129; + static constexpr uint16_t MESSAGE_TYPE = 129; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request"); } @@ -3045,11 +3143,28 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; +class ZWaveProxyRequestResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 151; + static constexpr uint8_t ESTIMATED_SIZE = 4; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request_response"); } +#endif + enums::ZWaveProxyRequestType type{}; + enums::ZWaveProxyStatus status{}; + 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: +}; #endif #ifdef USE_INFRARED class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 135; + static constexpr uint16_t MESSAGE_TYPE = 135; static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_infrared_response"); } @@ -3068,7 +3183,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 136; + static constexpr uint16_t MESSAGE_TYPE = 136; static constexpr uint8_t ESTIMATED_SIZE = 224; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); } @@ -3094,7 +3209,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { }; class InfraredRFReceiveEvent final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 137; + static constexpr uint16_t MESSAGE_TYPE = 137; static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("infrared_rf_receive_event"); } @@ -3116,7 +3231,7 @@ class InfraredRFReceiveEvent final : public ProtoMessage { #ifdef USE_RADIO_FREQUENCY class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 148; + static constexpr uint16_t MESSAGE_TYPE = 148; static constexpr uint8_t ESTIMATED_SIZE = 56; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); } @@ -3137,7 +3252,7 @@ class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage #ifdef USE_SERIAL_PROXY class SerialProxyConfigureRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 138; + static constexpr uint16_t MESSAGE_TYPE = 138; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_configure_request"); } @@ -3157,7 +3272,7 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { }; class SerialProxyDataReceived final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 139; + static constexpr uint16_t MESSAGE_TYPE = 139; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_data_received"); } @@ -3179,7 +3294,7 @@ class SerialProxyDataReceived final : public ProtoMessage { }; class SerialProxyWriteRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 140; + static constexpr uint16_t MESSAGE_TYPE = 140; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_write_request"); } @@ -3197,7 +3312,7 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 141; + static constexpr uint16_t MESSAGE_TYPE = 141; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_set_modem_pins_request"); } @@ -3213,7 +3328,7 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 142; + static constexpr uint16_t MESSAGE_TYPE = 142; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_request"); } @@ -3228,13 +3343,14 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 143; - static constexpr uint8_t ESTIMATED_SIZE = 8; + static constexpr uint16_t MESSAGE_TYPE = 143; + static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_response"); } #endif uint32_t instance{0}; uint32_t line_states{0}; + enums::SerialProxyStatus status{}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -3245,7 +3361,7 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage { }; class SerialProxyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 144; + static constexpr uint16_t MESSAGE_TYPE = 144; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_request"); } @@ -3261,7 +3377,7 @@ class SerialProxyRequest final : public ProtoDecodableMessage { }; class SerialProxyRequestResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 147; + static constexpr uint16_t MESSAGE_TYPE = 147; static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_request_response"); } @@ -3279,10 +3395,10 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 145; + static constexpr uint16_t MESSAGE_TYPE = 145; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_set_connection_params_request"); } @@ -3301,7 +3417,7 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 146; + static constexpr uint16_t MESSAGE_TYPE = 146; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_set_connection_params_response"); } diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 8ebd60fb5d..3603fac6d7 100644 --- a/esphome/components/api/api_pb2_defines.h +++ b/esphome/components/api/api_pb2_defines.h @@ -3,7 +3,7 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_BLUETOOTH_PROXY +#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS) #ifndef USE_API_VARINT64 #define USE_API_VARINT64 #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 850ad37bc9..846c0ad652 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: @@ -574,7 +584,7 @@ template<> const char *proto_enum_to_string(enu } } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS template<> const char *proto_enum_to_string(enums::BluetoothDeviceRequestType value) { switch (value) { @@ -596,6 +606,8 @@ const char *proto_enum_to_string(enums::Bluet return ESPHOME_PSTR("UNKNOWN"); } } +#endif +#ifdef USE_BLUETOOTH_PROXY template<> const char *proto_enum_to_string(enums::BluetoothScannerState value) { switch (value) { case enums::BLUETOOTH_SCANNER_STATE_IDLE: @@ -804,6 +816,18 @@ template<> const char *proto_enum_to_string(enums: return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::ZWaveProxyStatus value) { + switch (value) { + case enums::ZWAVE_PROXY_STATUS_OK: + return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_OK"); + case enums::ZWAVE_PROXY_STATUS_IN_USE: + return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_IN_USE"); + case enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED: + return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_NOT_SUPPORTED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif #ifdef USE_SERIAL_PROXY template<> const char *proto_enum_to_string(enums::SerialProxyParity value) { @@ -826,6 +850,10 @@ template<> const char *proto_enum_to_string(enums return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE"); case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_FLUSH"); + case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -842,6 +870,10 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("SERIAL_PROXY_STATUS_TIMEOUT"); case enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED: return ESPHOME_PSTR("SERIAL_PROXY_STATUS_NOT_SUPPORTED"); + case enums::SERIAL_PROXY_STATUS_PORT_IN_USE: + return ESPHOME_PSTR("SERIAL_PROXY_STATUS_PORT_IN_USE"); + case enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT: + return ESPHOME_PSTR("SERIAL_PROXY_STATUS_INVALID_ARGUMENT"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -864,7 +896,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 { @@ -901,6 +934,7 @@ const char *SerialProxyInfo::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyInfo")); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("port_type"), static_cast(this->port_type)); + dump_field(out, ESPHOME_PSTR("configured_line_states"), this->configured_line_states); return out.c_str(); } #endif @@ -965,6 +999,58 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #ifdef USE_ZWAVE_PROXY dump_field(out, ESPHOME_PSTR("zwave_home_id"), this->zwave_home_id); #endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": "); + 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(); +} +#ifdef USE_BLUETOOTH_PROXY +const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); + return out.c_str(); +} +#endif +#ifdef USE_VOICE_ASSISTANT +const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif +#ifdef USE_ZWAVE_PROXY +const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("home_id"), this->home_id); + return out.c_str(); +} +#endif +const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); +#ifdef USE_BLUETOOTH_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": "); + this->bluetooth_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_VOICE_ASSISTANT + out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": "); + this->voice_assistant.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_ZWAVE_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": "); + this->zwave_proxy.dump_to(out); + out.append("\n"); +#endif #ifdef USE_SERIAL_PROXY for (const auto &it : this->serial_proxies) { out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": "); @@ -1403,7 +1489,7 @@ const char *ParsedTimezone::dump_to(DumpBuffer &out) const { const char *GetTimeResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("GetTimeResponse")); dump_field(out, ESPHOME_PSTR("epoch_seconds"), this->epoch_seconds); - dump_field(out, ESPHOME_PSTR("timezone"), this->timezone); + dump_field(out, ESPHOME_PSTR("has_parsed_timezone"), this->has_parsed_timezone); out.append(2, ' ').append_p(ESPHOME_PSTR("parsed_timezone")).append(": "); this->parsed_timezone.dump_to(out); out.append("\n"); @@ -1876,7 +1962,6 @@ const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const { #endif dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default); dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category)); - dump_field(out, ESPHOME_PSTR("supports_pause"), this->supports_pause); for (const auto &it : this->supported_formats) { out.append(4, ' ').append_p(ESPHOME_PSTR("supported_formats")).append(": "); it.dump_to(out); @@ -1939,6 +2024,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const } return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); @@ -2110,6 +2197,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error"), this->error); return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse")); dump_field(out, ESPHOME_PSTR("state"), static_cast(this->state)); @@ -2575,6 +2664,12 @@ const char *ZWaveProxyRequest::dump_to(DumpBuffer &out) const { dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); return out.c_str(); } +const char *ZWaveProxyRequestResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyRequestResponse")); + dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); + return out.c_str(); +} #endif #ifdef USE_INFRARED const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { @@ -2684,6 +2779,7 @@ const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetModemPinsResponse")); dump_field(out, ESPHOME_PSTR("instance"), this->instance); dump_field(out, ESPHOME_PSTR("line_states"), this->line_states); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); return out.c_str(); } const char *SerialProxyRequest::dump_to(DumpBuffer &out) const { @@ -2701,7 +2797,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); 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..65c7b8858c 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: { @@ -300,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothDeviceRequest::MESSAGE_TYPE: { BluetoothDeviceRequest msg; msg.decode(msg_data, msg_size); @@ -311,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: { BluetoothGATTGetServicesRequest msg; msg.decode(msg_data, msg_size); @@ -322,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadRequest::MESSAGE_TYPE: { BluetoothGATTReadRequest msg; msg.decode(msg_data, msg_size); @@ -333,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteRequest::MESSAGE_TYPE: { BluetoothGATTWriteRequest msg; msg.decode(msg_data, msg_size); @@ -344,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTReadDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -355,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTWriteDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -366,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTNotifyRequest::MESSAGE_TYPE: { BluetoothGATTNotifyRequest msg; msg.decode(msg_data, msg_size); @@ -377,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); @@ -692,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: { BluetoothSetConnectionParamsRequest msg; msg.decode(msg_data, msg_size); @@ -703,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif + case 149 /* DeviceCapabilitiesRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_capabilities_request")); +#endif + this->on_device_capabilities_request(); + break; + } default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index aca42ca303..6abdf7093e 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -21,12 +21,14 @@ 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(){}; void on_device_info_request(){}; + void on_device_capabilities_request(){}; + void on_list_entities_request(){}; void on_subscribe_states_request(){}; @@ -113,32 +115,32 @@ class APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_subscribe_bluetooth_connections_free_request(){}; #endif @@ -233,7 +235,7 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif }; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6c26c4e187..751f2e4c3b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -1,6 +1,7 @@ #include "api_server.h" #ifdef USE_API #include +#include #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" @@ -30,11 +31,6 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c APIServer::APIServer() { global_api_server = this; } -// Custom deleter defined here so `delete` sees the complete APIConnection type. -// This prevents libc++ from emitting an "incomplete type" error when other -// translation units only have the forward declaration of APIConnection. -void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; } - void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); @@ -111,8 +107,32 @@ 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. + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } + } + }); + } +#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")); } } @@ -125,8 +145,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"); @@ -190,11 +212,16 @@ void APIServer::remove_client_(uint8_t client_index) { if (client_index < last_index) { std::swap(this->clients_[client_index], this->clients_[last_index]); } - this->clients_[last_index].reset(); + // Drop the count before resetting the slot. reset() runs ~APIConnection(), which can reenter the + // server (e.g. voice_assistant unsubscribes in its disconnect trigger, publishing entity state -> + // on_*_update iterating active_clients()). Excluding the dying slot from the active range first + // keeps that reentrant iteration from dereferencing the now-null slot. this->api_connection_count_--; + 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 +259,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 +267,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()) { @@ -368,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) { void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { // We could add code to manage a second subscription type, but, since this message type is // very infrequent and small, we simply send it to all clients - for (auto &c : this->active_clients()) - c->send_message(msg); + for (auto &c : this->active_clients()) { + if (!c->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } + } } #endif @@ -392,16 +423,18 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel) #endif -float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; } - -void APIServer::set_port(uint16_t port) { this->port_ = port; } - -void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } - #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 @@ -514,10 +547,6 @@ const std::vector &APIServer::get_sta } #endif -uint16_t APIServer::get_port() const { return this->port_; } - -void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } - #ifdef USE_API_NOISE bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active) { @@ -542,7 +571,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); for (auto &c : this->active_clients()) { DisconnectRequest req; - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } @@ -557,7 +588,7 @@ bool APIServer::load_and_apply_noise_psk_() { return true; } -bool APIServer::save_noise_psk(psk_t psk, bool make_active) { +bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { #ifdef USE_API_NOISE_PSK_FROM_YAML // When PSK is set from YAML, this function should never be called // but if it is, reject the change @@ -571,8 +602,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 +622,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 @@ -682,7 +729,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn // Schedule automatic cleanup after timeout (client will have given up by then) // Uses numeric ID overload to avoid heap allocation from str_sprintf this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() { - ESP_LOGD(TAG, "Action call %u timed out", action_call_id); + ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id); this->unregister_active_action_call(action_call_id); }); @@ -726,7 +773,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message, @@ -738,7 +785,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri return; } } - ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id); } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 6b575e536d..072a583901 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -3,7 +3,12 @@ #include "esphome/core/defines.h" #ifdef USE_API #include "api_buffer.h" -#include "api_noise_context.h" +// Must precede clients_ so APIConnection is complete for default_delete (libc++). +#include "api_connection.h" +#ifdef USE_API_NOISE +// Only present in the build when the noise component is loaded +#include "esphome/components/noise/noise.h" +#endif #include "api_pb2.h" #include "api_pb2_service.h" #include "esphome/components/socket/socket.h" @@ -12,8 +17,9 @@ #include "esphome/core/controller.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" -#include "list_entities.h" -#include "subscribe_state.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -34,7 +40,7 @@ class UserServiceDescriptor; #ifdef USE_API_NOISE struct SavedNoisePsk { - psk_t psk; + noise::psk_t psk; } PACKED; // NOLINT #endif @@ -48,8 +54,8 @@ class APIServer final : public Component, public: APIServer(); void setup() override; - uint16_t get_port() const; - float get_setup_priority() const override; + uint16_t get_port() const { return this->port_; } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void loop() override; void dump_config() override; void on_shutdown() override; @@ -60,9 +66,9 @@ class APIServer final : public Component, #ifdef USE_CAMERA void on_camera_image(const std::shared_ptr &image) override; #endif - void set_port(uint16_t port); - void set_reboot_timeout(uint32_t reboot_timeout); - void set_batch_delay(uint16_t batch_delay); + void set_port(uint16_t port) { this->port_ = port; } + void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } + void set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; } uint16_t get_batch_delay() const { return batch_delay_; } void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } @@ -70,10 +76,10 @@ class APIServer final : public Component, APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; } #ifdef USE_API_NOISE - bool save_noise_psk(psk_t psk, bool make_active = true); + bool save_noise_psk(noise::psk_t psk, bool make_active = true); bool clear_noise_psk(bool make_active = true); - void set_noise_psk(psk_t psk) { this->noise_ctx_.set_psk(psk); } - APINoiseContext &get_noise_ctx() { return this->noise_ctx_; } + void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); } + noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE void handle_disconnect(APIConnection *conn); @@ -191,15 +197,9 @@ class APIServer final : public Component, bool is_connected_with_state_subscription() const; // Range-for view over the populated slice [0, api_connection_count_). Read-only with respect - // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the + // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the // APIConnection but cannot reset/move the slot and break the count invariant. - // Custom deleter is defined out-of-line in api_server.cpp so libc++ does not - // eagerly instantiate `delete static_cast(p)` here, where - // only the forward declaration of APIConnection is visible (incomplete type). - struct APIConnectionDeleter { - void operator()(APIConnection *p) const; - }; - using APIConnectionPtr = std::unique_ptr; + using APIConnectionPtr = std::unique_ptr; class ActiveClientsView { const APIConnectionPtr *begin_; const APIConnectionPtr *end_; @@ -261,6 +261,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); @@ -338,17 +351,20 @@ 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_; + noise::NoiseContext noise_ctx_; ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; 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/client.py b/esphome/components/api/client.py index d6150fbd29..5e1c88b2ca 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -1,170 +1,10 @@ -from __future__ import annotations +"""Backward-compatibility shim; the log client lives in esphome.api_client. -import asyncio -from datetime import datetime -import importlib -import logging -from typing import TYPE_CHECKING, Any -import warnings +Importing this module executes the whole api component package, which pulls +in the validation stack. CLI code paths should import esphome.api_client +directly so the logs fast path stays light. +""" -# Suppress protobuf version warnings -with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", category=UserWarning, message=".*Protobuf gencode version.*" - ) - from aioesphomeapi import APIClient, parse_log_message - from aioesphomeapi.log_runner import async_run +from esphome.api_client import async_run_logs, run_logs -import contextlib - -from esphome.const import CONF_KEY, CONF_PORT, __version__ -from esphome.core import CORE, EsphomeError -from esphome.util import safe_print - -from . import CONF_ENCRYPTION - -if TYPE_CHECKING: - from aioesphomeapi.api_pb2 import ( - SubscribeLogsResponse, # pylint: disable=no-name-in-module - ) - - -_LOGGER = logging.getLogger(__name__) - - -class _LogLineProcessor: - """Feeds incoming log lines to the stack-trace decoder. - - Two responsibilities beyond just calling the decoder: - 1. Catch EsphomeError. on_log runs inside an asyncio protocol - callback; if an exception escapes, the loop tears the transport - down with "Fatal error: protocol.data_received() call failed." - and ReconnectLogic immediately reconnects, the device replays - the same crash trace, and we loop forever. - 2. Disable decoding after the first failure. _decode_pc shells out - to PlatformIO via _run_idedata, which is expensive; a single - crash dump can contain many PC/BT lines and we don't want to - retry the failing subprocess for each one. - """ - - def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None: - self._config = config - self._platform_handler = platform_handler - self._decode_enabled = True - self.backtrace_state = False - - def process_line(self, raw_line: str) -> None: - if not self._decode_enabled: - return - try: - if self._platform_handler is not None: - self.backtrace_state = self._platform_handler( - self._config, raw_line, self.backtrace_state - ) - except EsphomeError as exc: - self._decode_enabled = False - self.backtrace_state = False - # _run_idedata raises EsphomeError with no message; fall back - # to a generic explanation when str(exc) is empty. - detail = str(exc) or "build artifacts not found locally" - _LOGGER.warning( - "Crash trace decoding unavailable: %s. " - "Run 'esphome compile' for this device to enable PC decoding.", - detail, - ) - - -async def async_run_logs( - config: dict[str, Any], - addresses: list[str], - subscribe_states: bool = True, -) -> None: - """Run the logs command in the event loop.""" - conf = config["api"] - name = config["esphome"]["name"] - port: int = int(conf[CONF_PORT]) - noise_psk: str | None = None - if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)): - noise_psk = key - - if len(addresses) == 1: - _LOGGER.info("Starting log output from %s using esphome API", addresses[0]) - else: - _LOGGER.info( - "Starting log output from %s using esphome API", " or ".join(addresses) - ) - - cli = APIClient( - addresses[0], # Primary address for compatibility - port, - "", # Password auth removed in 2026.1.0 - client_info=f"ESPHome Logs {__version__}", - noise_psk=noise_psk, - addresses=addresses, # Pass all addresses for automatic retry - ) - - # Try platform-specific stacktrace handler first, fall back to generic - platform_process_stacktrace = None - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - platform_process_stacktrace = getattr(module, "process_stacktrace") - except (AttributeError, ImportError): - _LOGGER.info( - 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".', - CORE.target_platform, - ) - - processor = _LogLineProcessor(config, platform_process_stacktrace) - - def on_log(msg: SubscribeLogsResponse) -> None: - """Handle a new log message.""" - time_ = datetime.now() - message: bytes = msg.message - text = message.decode("utf8", "backslashreplace") - nanoseconds = time_.microsecond // 1000 - timestamp = ( - f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" - ) - for parsed_msg in parse_log_message(text, timestamp): - # safe_print handles the dashboard \033 escaping and falls back - # to backslashreplace encoding on stdouts that can't represent - # the wifi signal-bar block characters (Windows redirected - # cp1252 pipe). - safe_print(parsed_msg) - for raw_line in text.splitlines(): - processor.process_line(raw_line) - - # Safe to fall back to plaintext here only for this diagnostics use - # case: the stream is one-way from device to client, and this code - # never accepts commands or acts on any message the device sends. - # An on-path attacker could still both inject fabricated log lines - # and passively read the device's log output (and any state data - # delivered when subscribe_states is enabled), so this does lose - # confidentiality as well as authentication/integrity. That tradeoff - # is acceptable for operator-visible logs, which aioesphomeapi also - # warns may come from an unverified device. Never mirror this opt-in - # for any connection that sends data to the device or uses Home - # Assistant actions. - stop = await async_run( - cli, - on_log, - name=name, - subscribe_states=subscribe_states, - allow_plaintext_fallback=True, - ) - try: - await asyncio.Event().wait() - finally: - await stop() - - -def run_logs( - config: dict[str, Any], - addresses: list[str], - subscribe_states: bool = True, -) -> None: - """Run the logs command.""" - with contextlib.suppress(KeyboardInterrupt): - asyncio.run( - async_run_logs(config, addresses, subscribe_states=subscribe_states) - ) +__all__ = ["async_run_logs", "run_logs"] 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/list_entities.cpp b/esphome/components/api/list_entities.cpp index f9e645b506..57ff616ca7 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -95,9 +95,17 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done( ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {} #ifdef USE_API_USER_DEFINED_ACTIONS +// Yield after every Nth service; bounds direct (non-batched) writes per loop pass +static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3; + bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { auto resp = service->encode_list_service_response(); - return this->client_->send_message(resp); + if (!this->client_->send_message(resp)) + return false; + // at_ is this service's index + if ((this->at_ + 1) % SERVICE_YIELD_INTERVAL == 0) + this->yield_after_step_(); + return true; } #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index f058f6af22..a226e080e8 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -684,11 +684,6 @@ class ProtoSize { return value < VARINT_THRESHOLD_1_BYTE ? 1 : (value < VARINT_THRESHOLD_2_BYTE ? 2 : 3); } - // Varint encoded length for an 8-bit value (1 or 2 bytes). - static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint8(uint8_t value) { - return value < VARINT_THRESHOLD_1_BYTE ? 1 : 2; - } - /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * 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/__init__.py b/esphome/components/aqi/__init__.py index 4b979ab406..17d434294a 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType") CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" +CONF_EXTENDED_RANGE = "extended_range" AQI_CALCULATION_TYPE = { "CAQI": AQICalculatorType.CAQI_TYPE, diff --git a/esphome/components/aqi/abstract_aqi_calculator.h b/esphome/components/aqi/abstract_aqi_calculator.h index 299962fa17..6b4c9c5e04 100644 --- a/esphome/components/aqi/abstract_aqi_calculator.h +++ b/esphome/components/aqi/abstract_aqi_calculator.h @@ -6,7 +6,7 @@ namespace esphome::aqi { class AbstractAQICalculator { public: - virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0; + virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0; }; } // namespace esphome::aqi diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h index bb8e402280..56b6069118 100644 --- a/esphome/components/aqi/aqi_calculator.h +++ b/esphome/components/aqi/aqi_calculator.h @@ -11,10 +11,12 @@ namespace esphome::aqi { class AQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { - float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); - float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override { + float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range); + float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + // extended_range lets the index run past the standard maximum, so clamp to the sensor's range. + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } @@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator { {35.5f, 55.5f}, {55.5f, 125.5f}, {125.5f, 225.5f}, - {225.5f, std::numeric_limits::max()} + {225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3 // clang-format on }; @@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator { {155.0f, 255.0f}, {255.0f, 355.0f}, {355.0f, 425.0f}, - {425.0f, std::numeric_limits::max()} + {425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band) // clang-format on }; - static float calculate_index(float value, const float array[NUM_LEVELS][2]) { + static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) { int grid_index = get_grid_index(value, array); if (grid_index == -1) { return -1.0f; @@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; - return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; + + // Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we + // clamp to the standard maximum; with extended_range we keep the extrapolated "over-range" + // value so heavy pollution reports numbers beyond what the standard defines. + if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) { + return aqi_hi; + } + return index; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it, + // and calculate_index() decides whether to clamp or extrapolate. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index 2d8a780cc7..4bb964d5ee 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -24,6 +24,7 @@ void AQISensor::setup() { void AQISensor::dump_config() { ESP_LOGCONFIG(TAG, "AQI Sensor:"); ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI"); + ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled"); if (this->pm_2_5_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str()); } @@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() { return; } - uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_); + uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_); this->publish_state(aqi); } diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 2e526ca825..464c088188 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; @@ -14,6 +14,7 @@ class AQISensor : public sensor::Sensor, public Component { void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; } void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; } void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; } + void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; } protected: void calculate_aqi_(); @@ -21,6 +22,7 @@ class AQISensor : public sensor::Sensor, public Component { sensor::Sensor *pm_2_5_sensor_{nullptr}; sensor::Sensor *pm_10_0_sensor_{nullptr}; AQICalculatorType aqi_calc_type_{AQI_TYPE}; + bool extended_range_{false}; AQICalculatorFactory aqi_calculator_factory_; float pm_2_5_value_{NAN}; diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h index 3f6da45aa9..56a98682d9 100644 --- a/esphome/components/aqi/caqi_calculator.h +++ b/esphome/components/aqi/caqi_calculator.h @@ -9,25 +9,28 @@ namespace esphome::aqi { class CAQICalculator : public AbstractAQICalculator { public: - uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override { + // The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We + // therefore always extrapolate the top band past 100 without limit, so the extended_range flag + // (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored. + uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override { float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID); float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID); float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f}); + aqi = std::min(aqi, static_cast(std::numeric_limits::max())); return static_cast(std::lround(aqi)); } protected: - static constexpr int NUM_LEVELS = 5; + static constexpr int NUM_LEVELS = 4; - static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}}; + static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}}; static constexpr float PM2_5_GRID[NUM_LEVELS][2] = { // clang-format off {0.0f, 15.1f}, {15.1f, 30.1f}, {30.1f, 55.1f}, - {55.1f, 110.1f}, - {110.1f, std::numeric_limits::max()} + {55.1f, 110.1f} // clang-format on }; @@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator { {0.0f, 25.1f}, {25.1f, 50.1f}, {50.1f, 90.1f}, - {90.1f, 180.1f}, - {180.1f, std::numeric_limits::max()} + {90.1f, 180.1f} // clang-format on }; @@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator { float conc_lo = array[grid_index][0]; float conc_hi = array[grid_index][1]; + // The top band is open-ended (see get_grid_index), so for concentrations above the last + // breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class. return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo; } static int get_grid_index(float value, const float array[NUM_LEVELS][2]) { for (int i = 0; i < NUM_LEVELS; i++) { - const bool in_range = - (value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive - : (value < array[i][1])); // others exclusive on hi + // The top band is open-ended: any value at or above its lower breakpoint falls into it. + const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]); if (in_range) { return i; } diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index 5842aea88c..a98d977227 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -7,15 +7,27 @@ from esphome.const import ( DEVICE_CLASS_AQI, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType -from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns +from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["sensor"] AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = ( + +def _validate_extended_range(config: ConfigType) -> ConfigType: + if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI": + raise cv.Invalid( + f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. " + "CAQI has no maximum value by specification, so it is always reported unbounded.", + [CONF_EXTENDED_RANGE], + ) + return config + + +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( AQISensor, accuracy_decimals=0, @@ -29,13 +41,15 @@ CONFIG_SCHEMA = ( cv.Required(CONF_CALCULATION_TYPE): cv.enum( AQI_CALCULATION_TYPE, upper=True ), + cv.Optional(CONF_EXTENDED_RANGE): cv.boolean, } ) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + _validate_extended_range, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -46,3 +60,4 @@ async def to_code(config): cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor)) cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE])) + cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False))) diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index 70015c53b9..bd02d22d1b 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -14,6 +14,8 @@ from esphome.const import ( CONF_TUNE_ANTENNA, CONF_WATCHDOG_THRESHOLD, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType MULTI_CONF = True @@ -42,7 +44,7 @@ AS3935_SCHEMA = cv.Schema( ) -async def setup_as3935(var, config): +async def setup_as3935(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN]) diff --git a/esphome/components/as3935/binary_sensor.py b/esphome/components/as3935/binary_sensor.py index 10004e69dc..929b653294 100644 --- a/esphome/components/as3935/binary_sensor.py +++ b/esphome/components/as3935/binary_sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -13,7 +14,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) var = await binary_sensor.new_binary_sensor(config) cg.add(hub.set_thunder_alert_binary_sensor(var)) diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index 9b43155563..b727b8fdb9 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_KILOMETER, ) +from esphome.types import ConfigType from . import AS3935, CONF_AS3935_ID @@ -31,7 +32,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) if distance_config := config.get(CONF_DISTANCE): diff --git a/esphome/components/as3935_i2c/__init__.py b/esphome/components/as3935_i2c/__init__.py index 09b588cb0c..83924de760 100644 --- a/esphome/components/as3935_i2c/__init__.py +++ b/esphome/components/as3935_i2c/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import as3935, i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["as3935"] DEPENDENCIES = ["i2c"] @@ -20,7 +21,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await as3935.setup_as3935(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index 4c1020daa7..b3d015114f 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t I2CAS3935Component::read_register(uint8_t reg) { uint8_t value; - if (write(®, 1) != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Writing register failed!"); - return 0; - } - if (read(&value, 1) != i2c::ERROR_OK) { + if (!this->read_byte(reg, &value)) { ESP_LOGW(TAG, "Reading register failed!"); return 0; } 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/__init__.py b/esphome/components/as3935_spi/__init__.py index f4cf07a906..332a51c7a9 100644 --- a/esphome/components/as3935_spi/__init__.py +++ b/esphome/components/as3935_spi/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import as3935, spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["as3935"] DEPENDENCIES = ["spi"] @@ -20,7 +21,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await as3935.setup_as3935(var, config) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/as5600/__init__.py index 444306cec3..780712c3bd 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + from esphome import pins import esphome.codegen as cg from esphome.components import i2c @@ -11,6 +14,7 @@ from esphome.const import ( CONF_RANGE, CONF_WATCHDOG, ) +from esphome.types import ConfigType CODEOWNERS = ["@ammmze"] DEPENDENCIES = ["i2c"] @@ -72,13 +76,13 @@ POSITION_TO_ANGLE = 360 / RESOLUTION MIN_RANGE = round(18 * ANGLE_TO_POSITION) -def angle(min=-360, max=360): +def angle(min: float = -360, max: float = 360) -> Callable[[Any], Any]: return cv.All( cv.float_with_unit("angle", "(°|deg)"), cv.float_range(min=min, max=max) ) -def angle_to_position(value, min=-360, max=360): +def angle_to_position(value: Any, min: float = -360, max: float = 360) -> int: try: value = angle(min=min, max=max)(value) return (RESOLUTION + round(value * ANGLE_TO_POSITION)) % RESOLUTION @@ -86,21 +90,21 @@ def angle_to_position(value, min=-360, max=360): raise cv.Invalid(f"When using angle, {e.error_message}") from e -def percent_to_position(value): +def percent_to_position(value: Any) -> int: value = cv.possibly_negative_percentage(value) return (RESOLUTION + round(value * RESOLUTION)) % RESOLUTION -def position(min=-MAX_POSITION, max=MAX_POSITION): +def position(min: int = -MAX_POSITION, max: int = MAX_POSITION) -> Callable[[Any], Any]: """Validate that the config option is a position. Accepts integers, degrees, or percentage (of 360 degrees). """ - def validator(value): + def validator(value: Any) -> int: if isinstance(value, str) and value.endswith("%"): value = percent_to_position(value) - if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")): + if isinstance(value, str) and value.endswith(("°", "deg")): return angle_to_position( value, min=round(min * POSITION_TO_ANGLE), @@ -112,7 +116,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION): return validator -def position_range(): +def position_range() -> Callable[[Any], Any]: """Validate that value given is a valid range for the device. A valid range is one of the following: - a value of 0 (meaning full range) @@ -129,7 +133,7 @@ def position_range(): zero_validator, ) - def validator(value): + def validator(value: Any) -> Any: is_negative_str = isinstance(value, str) and value.startswith("-") is_negative_num = isinstance(value, (float, int)) and value < 0 if is_negative_str or is_negative_num: @@ -139,13 +143,13 @@ def position_range(): return validator -def has_valid_range_config(): +def has_valid_range_config() -> Callable[[ConfigType], ConfigType]: """Validate that that the config start + end position results in a valid positional range, which must be >= 18degrees """ range_validator = position_range() - def validator(config): + def validator(config: ConfigType) -> ConfigType: # if we don't have an end position, then there is nothing to do if CONF_END_POSITION not in config: return config @@ -203,7 +207,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/as5600/sensor/__init__.py index cf67a3f203..847b89f121 100644 --- a/esphome/components/as5600/sensor/__init__.py +++ b/esphome/components/as5600/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import AS5600Component, as5600_ns @@ -77,7 +78,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_AS5600_ID]) await cg.register_component(var, config) 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..f70c5e999f 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -5,10 +5,11 @@ from esphome.const import ( CONF_CLEAR, CONF_GAIN, CONF_ID, - DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_EMPTY, ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@mrgnr"] DEPENDENCIES = ["i2c"] @@ -54,7 +55,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, ) @@ -96,7 +97,7 @@ SENSORS = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 2a07903b68..31007022d5 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -13,7 +14,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"] @@ -25,7 +26,7 @@ CONFIG_SCHEMA = cv.Schema({}) @coroutine_with_priority(CoroPriority.NETWORK_TRANSPORT) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CORE.is_esp32: # https://github.com/ESP32Async/AsyncTCP from esphome.components.esp32 import add_idf_component @@ -37,7 +38,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 +48,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/__init__.py b/esphome/components/at581x/__init__.py index 5031b72cce..193e62f615 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] DEPENDENCIES = ["i2c"] @@ -70,7 +73,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -91,7 +94,12 @@ AT581XSettingsAction = at581x_ns.class_("AT581XSettingsAction", automation.Actio ), synchronous=True, ) -async def at581x_reset_to_code(config, action_id, template_arg, args): +async def at581x_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -163,7 +171,12 @@ RADAR_SETTINGS_SCHEMA = cv.Schema( RADAR_SETTINGS_SCHEMA, synchronous=True, ) -async def at581x_settings_to_code(config, action_id, template_arg, args): +async def at581x_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) 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/__init__.py b/esphome/components/at581x/switch/__init__.py index 8e1b82b356..7e45ed89ec 100644 --- a/esphome/components/at581x/switch/__init__.py +++ b/esphome/components/at581x/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI +from esphome.types import ConfigType from .. import CONF_AT581X_ID, AT581XComponent, at581x_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = switch.switch_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: at581x_component = await cg.get_variable(config[CONF_AT581X_ID]) s = await switch.new_switch(config) await cg.register_parented(s, config[CONF_AT581X_ID]) 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..22cb2b3150 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -1,8 +1,6 @@ #include "atc_mithermometer.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { static const char *const TAG = "atc_mithermometer"; @@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() { LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); } -bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != this->address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device return success; } -optional ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { +optional ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) { ParseResult result; if (!service_data.uuid.contains(0x1A, 0x18)) { ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); @@ -65,12 +63,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; } @@ -133,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional &result, cons } } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h index 8f62f05bc1..3f5ca4c784 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.h +++ b/esphome/components/atc_mithermometer/atc_mithermometer.h @@ -2,12 +2,10 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::atc_mithermometer { struct ParseResult { @@ -18,11 +16,11 @@ struct ParseResult { int raw_offset; }; -class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } @@ -38,11 +36,11 @@ class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDevice sensor::Sensor *battery_voltage_{nullptr}; sensor::Sensor *signal_strength_{nullptr}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + uint8_t last_frame_count_{0}; + + optional parse_header_(const ble_device_base::ServiceData &service_data); bool parse_message_(const std::vector &message, ParseResult &result); bool report_results_(const optional &result, const char *address); }; } // namespace esphome::atc_mithermometer - -#endif diff --git a/esphome/components/atc_mithermometer/sensor.py b/esphome/components/atc_mithermometer/sensor.py index 5286d29d1b..184b2e8733 100644 --- a/esphome/components/atc_mithermometer/sensor.py +++ b/esphome/components/atc_mithermometer/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -21,17 +21,19 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType CODEOWNERS = ["@ahpohl"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer") ATCMiThermometer = atc_mithermometer_ns.class_( - "ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("atc_mithermometer"), cv.Schema( { cv.GenerateID(): cv.declare_id(ATCMiThermometer), @@ -71,15 +73,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) 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/atm90e26/sensor.py b/esphome/components/atm90e26/sensor.py index 5941cb35b4..87db214233 100644 --- a/esphome/components/atm90e26/sensor.py +++ b/esphome/components/atm90e26/sensor.py @@ -30,6 +30,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType CONF_METER_CONSTANT = "meter_constant" CONF_PL_CONST = "pl_const" @@ -123,7 +124,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/atm90e32/button/__init__.py index 19f62ccfbd..274cce6adb 100644 --- a/esphome/components/atm90e32/button/__init__.py +++ b/esphome/components/atm90e32/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -67,7 +68,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if run_gain := config.get(CONF_RUN_GAIN_CALIBRATION): 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/atm90e32/number/__init__.py b/esphome/components/atm90e32/number/__init__.py index 848680b875..9c2865dde3 100644 --- a/esphome/components/atm90e32/number/__init__.py +++ b/esphome/components/atm90e32/number/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_AMPERE, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import atm90e32_ns from ..sensor import ATM90E32Component @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if voltage_cfg := config.get(CONF_REFERENCE_VOLTAGE): diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index dc46138add..38b24c7cf6 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -41,6 +41,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from . import atm90e32_ns @@ -191,7 +192,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_instance_id(str(config[CONF_ID]))) await cg.register_component(var, config) diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor/__init__.py index ab96f6c207..30585cb873 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C +from esphome.types import ConfigType from ..sensor import ATM90E32Component @@ -34,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ID]) if phase_cfg := config.get(CONF_PHASE_STATUS): diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 44371e87ab..2a5304be77 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,10 +1,13 @@ +from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any import esphome.codegen as cg from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, include_builtin_idf_component, + require_certificate_bundle, ) import esphome.config_validation as cv from esphome.const import ( @@ -15,6 +18,7 @@ from esphome.const import ( ) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["ring_buffer"] CODEOWNERS = ["@kahrendt"] @@ -125,10 +129,10 @@ CONF_THREADSAFE = "threadsafe" _MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True) -def _maybe_empty_codec(schema): +def _maybe_empty_codec(schema: cv.Schema) -> Callable[[Any], Any]: """Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict.""" - def validator(value): + def validator(value: Any) -> Any: if value is None: value = {} return schema(value) @@ -200,14 +204,14 @@ def set_stream_limits( max_channels: int = cv.UNDEFINED, min_sample_rate: int = cv.UNDEFINED, max_sample_rate: int = cv.UNDEFINED, -): +) -> Callable[[ConfigType], None]: """Sets the limits for the audio stream that audio component can handle When the component sinks audio (e.g., a speaker), these indicate the limits to the audio it can receive. When the component sources audio (e.g., a microphone), these indicate the limits to the audio it can send. """ - def set_limits_in_config(config): + def set_limits_in_config(config: ConfigType) -> None: if min_bits_per_sample is not cv.UNDEFINED: config[CONF_MIN_BITS_PER_SAMPLE] = min_bits_per_sample if max_bits_per_sample is not cv.UNDEFINED: @@ -233,7 +237,7 @@ def final_validate_audio_schema( sample_rate: int = cv.UNDEFINED, enabled_channels: list[int] = cv.UNDEFINED, audio_device_issue: bool = False, -): +) -> cv.Schema: """Validates audio compatibility when passed between different components. The component derived from ``AUDIO_COMPONENT_SCHEMA`` should call ``set_stream_limits`` in a validator to specify its compatible settings @@ -251,7 +255,7 @@ def final_validate_audio_schema( audio_device_issue (bool, optional): Format the error message to indicate the problem is in the configuration for the ``audio_device`` component. Defaults to False. """ - def validate_audio_compatiblity(audio_config): + def validate_audio_compatiblity(audio_config: ConfigType) -> ConfigType: audio_schema = {} if bits_per_sample is not cv.UNDEFINED: @@ -329,13 +333,15 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N add_idf_sdkconfig_option(internal_key, True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) include_builtin_idf_component("esp_http_client") + # HTTPS streams verify the server against the root certificate bundle + require_certificate_bundle() add_idf_component( name="esphome/esp-audio-libs", - ref="3.0.0", + ref="3.2.1", ) data = _get_data() @@ -371,7 +377,7 @@ async def to_code(config): data.wav_support = True if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.2.0") + add_idf_component(name="esphome/micro-decoder", ref="0.4.0") # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash if not data.flac_support: @@ -380,6 +386,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) if not data.opus_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) + # Vorbis is unsupported in ESPHome, so always disable it + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False) if not data.wav_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) @@ -395,11 +403,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.0") + 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.cpp b/esphome/components/audio/audio.cpp index b0aa3c1abb..402e741059 100644 --- a/esphome/components/audio/audio.cpp +++ b/esphome/components/audio/audio.cpp @@ -86,7 +86,7 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url) // Match "audio/ogg" with a codecs parameter containing "opus" // Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc. // Plain "audio/ogg" without opus is not matched (almost always Ogg Vorbis) - if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) { + if (strncasecmp(content_type, "audio/ogg", 9) == 0 && str_contains_ignore_case(content_type + 9, "opus")) { return AudioFileType::OPUS; } #endif diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h index 62c57b18cf..36780a3055 100644 --- a/esphome/components/audio/audio.h +++ b/esphome/components/audio/audio.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" // for ESPDEPRECATED #include #include @@ -143,6 +144,8 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url); /// @param output_buffer Buffer to store the scaled samples /// @param scale_factor Q15 fixed point scaling factor /// @param samples_to_scale Number of samples to scale +// Remove before 2026.12.0 +ESPDEPRECATED("Use esp_audio_libs::gain::apply() (from ) instead. Removed in 2026.12.0.", "2026.6.0") void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, int16_t scale_factor, size_t samples_to_scale); diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index d4ff59fc36..fe9ad9c9ad 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -9,9 +9,12 @@ namespace esphome::audio { static const char *const TAG = "audio.decoder"; -static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data +// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block, +// before yielding and returning. +static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32; + static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10; AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) @@ -20,11 +23,13 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size) } esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) { - auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_); + // Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte + // aligned, so no frame alignment is required. + auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_); if (source == nullptr) { - return ESP_ERR_NO_MEM; + // create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size) + return ESP_ERR_INVALID_ARG; } - source->set_source(input_ring_buffer); this->input_buffer_ = std::move(source); return ESP_OK; } @@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } FileDecoderState state = FileDecoderState::MORE_TO_PROCESS; - - uint32_t decoding_start = millis(); - - bool first_loop_iteration = true; - - size_t bytes_processed = 0; - size_t bytes_available_before_processing = 0; + uint8_t no_output_iterations = 0; while (state == FileDecoderState::MORE_TO_PROCESS) { // Transfer decoded out @@ -161,45 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { this->playback_ms_ += this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_); } + + if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) { + // All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before + // decoding the next batch + return AudioDecoderState::DECODING; + } } else { // If paused, block to avoid wasting CPU resources delay(READ_WRITE_TIMEOUT_MS); } - // Verify there is enough space to store more decoded audio and that the function hasn't been running too long - if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) || - (millis() - decoding_start > DECODING_TIMEOUT_MS)) { + if (this->output_transfer_buffer_->available() > 0) { + // Output transfer buffer indicates backpressure, return so caller can handle other events; + // e.g., stop/pause, before trying again return AudioDecoderState::DECODING; } - // Decode more audio - - // Never shift the input buffer; every decoder buffers internally and consumes only what it processed. - size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - - if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) { - // Less data is available than what was processed in last iteration, so don't attempt to decode. - // This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer - // will shift the remaining data to the start and copy more from the source the next time the decode function is - // called - break; + // Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output + // stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this + // loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and + // the return keeps stop/pause responsive. + if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) { + delay(1); + return AudioDecoderState::DECODING; } - bytes_available_before_processing = this->input_buffer_->available(); + // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it + // processed, so the source does not need to accumulate or stitch chunks across fill() calls. + this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) { - // Failed to decode in last attempt and there is no new data + const size_t available_before_decode = this->input_buffer_->available(); - if ((this->input_buffer_->free() == 0) && first_loop_iteration) { - // The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact - // same data, we can never recover. For const sources this is correct: the entire file is already available, so - // a decode failure is genuine, not a transient out-of-data condition. - state = FileDecoderState::FAILED; - } else { - // Attempt to get more data next time - state = FileDecoderState::IDLE; - } - } else if (this->input_buffer_->available() == 0) { + if (available_before_decode == 0) { // No data to decode, attempt to get more data next time state = FileDecoderState::IDLE; } else { @@ -231,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } } - first_loop_iteration = false; - bytes_processed = bytes_available_before_processing - this->input_buffer_->available(); - if (state == FileDecoderState::POTENTIALLY_FAILED) { ++this->potentially_failed_count_; } else if (state == FileDecoderState::END_OF_FILE) { @@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { } else if (state == FileDecoderState::FAILED) { return AudioDecoderState::FAILED; } else if (state == FileDecoderState::MORE_TO_PROCESS) { - this->potentially_failed_count_ = 0; + // Reset the failsafe only when the iteration made forward progress: input was consumed or output was + // produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A + // MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it + // toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever. + if ((this->input_buffer_->available() < available_before_decode) || + (this->output_transfer_buffer_->available() > 0)) { + this->potentially_failed_count_ = 0; + } else { + ++this->potentially_failed_count_; + } } } return AudioDecoderState::DECODING; @@ -327,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/audio_decoder.h b/esphome/components/audio/audio_decoder.h index c34ebbc613..e772b7eb5f 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -61,15 +61,16 @@ class AudioDecoder { */ public: /// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source() - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioDecoder(size_t input_buffer_size, size_t output_buffer_size); ~AudioDecoder() = default; - /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr. + /// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource. + /// @param input_ring_buffer weak_ptr of the source ring buffer to read from + /// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr. diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp index c04cc881f5..bef62ce190 100644 --- a/esphome/components/audio/audio_resampler.cpp +++ b/esphome/components/audio/audio_resampler.cpp @@ -12,16 +12,17 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20; AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size) : input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) { - this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size); this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size); } esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) { - if (this->input_transfer_buffer_ != nullptr) { - this->input_transfer_buffer_->set_source(input_ring_buffer); - return ESP_OK; + // The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring + // buffer (stored here) and the input stream info (set by start()) are available, in either order. + this->source_ring_buffer_ = input_ring_buffer.lock(); + if (this->source_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; } - return ESP_ERR_NO_MEM; + return ESP_OK; } esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) { @@ -47,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI this->input_stream_info_ = input_stream_info; this->output_stream_info_ = output_stream_info; - if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) { + if (this->output_transfer_buffer_ == nullptr) { return ESP_ERR_NO_MEM; } @@ -56,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI return ESP_ERR_NOT_SUPPORTED; } + // Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks + // the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + return ESP_ERR_NOT_SUPPORTED; + } + if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) || (input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) { this->resampler_ = make_unique( @@ -87,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI } AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) { + if (this->audio_source_ == nullptr) { + // Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are + // never split across the ring buffer's wrap boundary. + const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1); + if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) { + // Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would + // truncate it and could yield a source that tears frames. + return AudioResamplerState::FAILED; + } + // Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our + // reference only after the source has taken ownership. + this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_, + static_cast(bytes_per_frame)); + if (this->audio_source_ == nullptr) { + return AudioResamplerState::FAILED; + } + this->source_ring_buffer_.reset(); + } + if (stop_gracefully) { - if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { + if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) { return AudioResamplerState::FINISHED; } } @@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d delay(READ_WRITE_TIMEOUT_MS); } - this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS)); + // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource + // (there is no intermediate transfer buffer to compact). + this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false); - if (this->input_transfer_buffer_->available() == 0) { + if (this->audio_source_->available() == 0) { // No samples available to process return AudioResamplerState::RESAMPLING; } @@ -112,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_free = this->output_transfer_buffer_->free(); const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free); - const size_t bytes_available = this->input_transfer_buffer_->available(); + const size_t bytes_available = this->audio_source_->available(); const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available); if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) || (this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) { // Adjust gain by -3 dB to avoid clipping due to the resampling process esp_audio_libs::resampler::ResamplerResults results = - this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(), - this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3); + this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(), + frames_available, frames_free, -3); - this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used)); + this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used)); this->output_transfer_buffer_->increase_buffer_length( this->output_stream_info_.frames_to_bytes(results.frames_generated)); @@ -146,10 +175,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free), this->input_stream_info_.frames_to_bytes(frames_available)); - std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), - (void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer); + std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(), + bytes_to_transfer); - this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer); + this->audio_source_->consume(bytes_to_transfer); this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer); } diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h index 575ad13692..c09070c0ce 100644 --- a/esphome/components/audio/audio_resampler.h +++ b/esphome/components/audio/audio_resampler.h @@ -22,7 +22,7 @@ namespace esphome::audio { enum class AudioResamplerState : uint8_t { RESAMPLING, // More data is available to resample FINISHED, // All file data has been resampled and transferred - FAILED, // Unused state included for consistency among Audio classes + FAILED, // Failed to allocate the audio source }; class AudioResampler { @@ -32,14 +32,16 @@ class AudioResampler { * component). Also supports converting bits per sample. */ public: - /// @brief Allocates the input and output transfer buffers - /// @param input_buffer_size Size of the input transfer buffer in bytes. + /// @brief Allocates the output transfer buffer. The input source is created later in resample(). + /// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source. /// @param output_buffer_size Size of the output transfer buffer in bytes. AudioResampler(size_t input_buffer_size, size_t output_buffer_size); - /// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr. - /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership - /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated + /// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy + /// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first + /// resample() call, so add_source() and start() may be called in any order. + /// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership + /// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive esp_err_t add_source(std::weak_ptr &input_ring_buffer); /// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr. @@ -78,7 +80,8 @@ class AudioResampler { void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; } protected: - std::unique_ptr input_transfer_buffer_; + std::shared_ptr source_ring_buffer_; + std::unique_ptr audio_source_; std::unique_ptr output_transfer_buffer_; size_t input_buffer_size_; diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index d9ce8060e2..a611549e58 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) { } } +void RingBufferAudioSource::clear_buffered_data() { + // Release the held item before reset() so the source no longer references memory the reset will reclaim. + if (this->acquired_item_ != nullptr) { + this->ring_buffer_->receive_release(this->acquired_item_); + this->acquired_item_ = nullptr; + } + this->current_data_ = nullptr; + this->current_available_ = 0; + this->queued_data_ = nullptr; + this->queued_length_ = 0; + this->item_trailing_ptr_ = nullptr; + this->item_trailing_length_ = 0; + this->splice_length_ = 0; + this->ring_buffer_->reset(); +} + bool RingBufferAudioSource::has_buffered_data() const { // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports. diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h index b713326141..074684f068 100644 --- a/esphome/components/audio/audio_transfer_buffer.h +++ b/esphome/components/audio/audio_transfer_buffer.h @@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer { /// exposure stays in place and fill() returns 0 until it is fully consumed. size_t fill(TickType_t ticks_to_wait, bool pre_shift) override; + /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight + /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread. + void clear_buffered_data(); + /// @brief Returns a mutable pointer to the currently exposed audio data. /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c3a4988b5..c2bdfb6cb0 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -2,7 +2,9 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MIC_GAIN -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -28,7 +30,12 @@ SET_MIC_GAIN_ACTION_SCHEMA = cv.maybe_simple_value( SET_MIC_GAIN_ACTION_SCHEMA, synchronous=True, ) -async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): +async def audio_adc_set_mic_gain_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -39,6 +46,6 @@ async def audio_adc_set_mic_gain_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_ADC") cg.add_global(audio_adc_ns.using) 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/__init__.py b/esphome/components/audio_dac/__init__.py index 46c277ce51..1351793afd 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -3,7 +3,9 @@ from esphome.automation import maybe_simple_id import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_VOLUME -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] IS_PLATFORM_COMPONENT = True @@ -37,7 +39,12 @@ SET_VOLUME_ACTION_SCHEMA = cv.maybe_simple_value( @automation.register_action( "audio_dac.mute_on", MuteOnAction, MUTE_ACTION_SCHEMA, synchronous=True ) -async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): +async def audio_dac_mute_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -48,7 +55,12 @@ async def audio_dac_mute_action_to_code(config, action_id, template_arg, args): SET_VOLUME_ACTION_SCHEMA, synchronous=True, ) -async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): +async def audio_dac_set_volume_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -59,6 +71,6 @@ async def audio_dac_set_volume_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_AUDIO_DAC") cg.add_global(audio_dac_ns.using) 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 23c90e9b76..d59ed7411a 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType: def _validate_file_shorthand(value: str) -> ConfigType: value = cv.string_strict(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return _file_schema( { CONF_TYPE: TYPE_WEB, @@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: else: raise cv.Invalid("Unsupported file source") - with open(path, "rb") as f: + with path.open("rb") as f: data = f.read() try: @@ -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/__init__.py b/esphome/components/audio_file/media_source/__init__.py index 635a51b610..0710582813 100644 --- a/esphome/components/audio_file/media_source/__init__.py +++ b/esphome/components/audio_file/media_source/__init__.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -21,19 +19,13 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioFileMediaSource, ) .extend( { - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ) .extend(cv.COMPONENT_SCHEMA), @@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() 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.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index 04b7d046e6..fb8620f7d9 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -30,8 +30,9 @@ void AudioHTTPMediaSource::dump_config() { ESP_LOGCONFIG(TAG, "Audio HTTP Media Source:\n" " Buffer Size: %zu bytes\n" + " Persistent Ring Buffer: %s\n" " Decoder Task Stack in PSRAM: %s", - this->buffer_size_, YESNO(this->decoder_task_stack_in_psram_)); + this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_)); } void AudioHTTPMediaSource::setup() { @@ -39,6 +40,7 @@ void AudioHTTPMediaSource::setup() { micro_decoder::DecoderConfig config; config.ring_buffer_size = this->buffer_size_; + config.persistent_ring_buffer = this->persistent_ring_buffer_; // Keep the transfer buffer smaller than the ring buffer so the reader can top up the ring // while the decoder is still draining it, instead of oscillating between empty and full. config.transfer_buffer_size = std::min(DEFAULT_TRANSFER_BUFFER_SIZE, this->buffer_size_ / 2); diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index e4bd69e9e6..a97025e53e 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; @@ -31,6 +33,7 @@ class AudioHTTPMediaSource : public Component, public media_source::MediaSource, void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } + void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; } // MediaSource interface implementation bool play_uri(const std::string &uri) override; @@ -52,6 +55,7 @@ class AudioHTTPMediaSource : public Component, public media_source::MediaSource, // on_audio_write(). Must be atomic to avoid a data race. std::atomic pause_{false}; bool decoder_task_stack_in_psram_{false}; + bool persistent_ring_buffer_{false}; }; } // namespace esphome::audio_http diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 519d8df698..14543957e9 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,7 +1,5 @@ -from typing import Any - import esphome.codegen as cg -from esphome.components import audio, esp32, media_source, psram +from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM from esphome.types import ConfigType @@ -9,6 +7,8 @@ from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] AUTO_LOAD = ["audio"] +CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" + audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( "AudioHTTPMediaSource", cg.Component, media_source.MediaSource @@ -20,14 +20,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config -def _validate_task_stack_in_psram(value: Any) -> bool: - # Only require the psram component when actually enabling PSRAM stacks; validating - # the boolean first means `false` doesn't trigger the requires_component check. - if value := cv.boolean(value): - return cv.requires_component(psram.DOMAIN)(value) - return value - - CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -37,7 +29,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range( min=5000, max=1000000 ), - cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, + cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA), @@ -53,7 +46,6 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + psram.request_external_task_stack() cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) + cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER])) diff --git a/esphome/components/axs15231/touchscreen/__init__.py b/esphome/components/axs15231/touchscreen/__init__.py index 8c18d8ca75..2616cb281b 100644 --- a/esphome/components/axs15231/touchscreen/__init__.py +++ b/esphome/components/axs15231/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 esphome.types import ConfigType from .. import axs15231_ns @@ -25,7 +26,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) 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.cpp b/esphome/components/b_parasite/b_parasite.cpp index 160d22a5b6..e0ae824b0d 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -1,8 +1,6 @@ #include "b_parasite.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::b_parasite { static const char *const TAG = "b_parasite"; @@ -16,7 +14,7 @@ void BParasite::dump_config() { LOG_SENSOR(" ", "Illuminance", this->illuminance_); } -bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) { if (device.address_uint64() != address_) { ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; @@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { } } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h index c719599b99..65540e82fb 100644 --- a/esphome/components/b_parasite/b_parasite.h +++ b/esphome/components/b_parasite/b_parasite.h @@ -2,18 +2,16 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::b_parasite { -class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; }; void set_bindkey(const std::string &bindkey); - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; } @@ -35,5 +33,3 @@ class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListene }; } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index 041303ad8b..cb8f569c0d 100644 --- a/esphome/components/b_parasite/sensor.py +++ b/esphome/components/b_parasite/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, @@ -20,17 +20,19 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType CODEOWNERS = ["@rbaron"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] b_parasite_ns = cg.esphome_ns.namespace("b_parasite") BParasite = b_parasite_ns.class_( - "BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BParasite", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("b_parasite"), cv.Schema( { cv.GenerateID(): cv.declare_id(BParasite), @@ -68,15 +70,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) 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/ballu/climate.py b/esphome/components/ballu/climate.py index 1127084632..c4d64cd692 100644 --- a/esphome/components/ballu/climate.py +++ b/esphome/components/ballu/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] CODEOWNERS = ["@bazuchan"] @@ -10,5 +11,5 @@ BalluClimate = ballu_ns.class_("BalluClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(BalluClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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/bang_bang/climate.py b/esphome/components/bang_bang/climate.py index bfdb12278f..65c5eaed18 100644 --- a/esphome/components/bang_bang/climate.py +++ b/esphome/components/bang_bang/climate.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_IDLE_ACTION, CONF_SENSOR, ) +from esphome.types import ConfigType bang_bang_ns = cg.esphome_ns.namespace("bang_bang") BangBangClimate = bang_bang_ns.class_("BangBangClimate", climate.Climate, cg.Component) @@ -41,7 +42,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index d4bf813846..1b967e665a 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import ble_client, time import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jhansche"] DEPENDENCIES = ["ble_client"] @@ -32,12 +34,12 @@ BEDJET_CLIENT_SCHEMA = cv.Schema( ) -async def register_bedjet_child(var, config): +async def register_bedjet_child(var: MockObj, config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_BEDJET_ID]) cg.add(parent.register_child(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await ble_client.register_ble_node(var, config) 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/__init__.py b/esphome/components/bedjet/climate/__init__.py index 4de9dcca0b..36650d643c 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate import esphome.config_validation as cv from esphome.const import CONF_HEAT_MODE, CONF_TEMPERATURE_SOURCE +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -37,7 +38,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) await register_bedjet_child(var, config) 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/__init__.py b/esphome/components/bedjet/fan/__init__.py index a4a611fefc..f5dfe32f4c 100644 --- a/esphome/components/bedjet/fan/__init__.py +++ b/esphome/components/bedjet/fan/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import fan import esphome.config_validation as cv +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -16,7 +17,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) await register_bedjet_child(var, config) 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/__init__.py b/esphome/components/bedjet/sensor/__init__.py index fa9ca7953e..595e798e49 100644 --- a/esphome/components/bedjet/sensor/__init__.py +++ b/esphome/components/bedjet/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType from .. import BEDJET_CLIENT_SCHEMA, bedjet_ns, register_bedjet_child @@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(BEDJET_CLIENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_bedjet_child(var, config) 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.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 4e22489844..0cf970b3cc 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip { static const char *const TAG = "beken_spi_led_strip"; -struct spi_data_t { +struct SpiData { SemaphoreHandle_t dma_tx_semaphore; volatile bool tx_in_progress; bool first_run; }; -static spi_data_t *spi_data = nullptr; +static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static void set_spi_ctrl_register(unsigned long bit, bool val) { +static void set_spi_ctrl_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CTRL); if (val == 0) { value &= ~bit; @@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) { REG_WRITE(SPI_CTRL, value); } -static void set_spi_config_register(unsigned long bit, bool val) { +static void set_spi_config_register(uint32_t bit, bool val) { uint32_t value = REG_READ(SPI_CONFIG); if (val == 0) { value &= ~bit; @@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) { void spi_dma_tx_enable(bool enable) { GDMA_CFG_ST en_cfg; - set_spi_config_register(SPI_TX_EN, enable ? 1 : 0); + set_spi_config_register(SPI_TX_EN, enable); en_cfg.channel = SPI_TX_DMA_CHANNEL; en_cfg.param = enable ? 1 : 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg); @@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) { param &= ~(SPI_CKR_MASK << SPI_CKR_POSI); param |= (div << SPI_CKR_POSI); REG_WRITE(SPI_CTRL, param); - ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div); + ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div); } void spi_dma_tx_finish_callback(unsigned int param) { spi_data->tx_in_progress = false; xSemaphoreGive(spi_data->dma_tx_semaphore); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); } void BekenSPILEDStripLightOutput::setup() { @@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() { return; } - spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t)); + spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) if (spi_data == nullptr) { ESP_LOGE(TAG, "Cannot allocate spi_data!"); this->mark_failed(); @@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() { spi_data->first_run = true; - set_spi_ctrl_register(MSTEN, 0); - set_spi_ctrl_register(BIT_WDTH, 0); + set_spi_ctrl_register(MSTEN, false); + set_spi_ctrl_register(BIT_WDTH, false); spi_set_clock(this->spi_frequency_); - set_spi_ctrl_register(CKPOL, 0); - set_spi_ctrl_register(CKPHA, 0); - set_spi_ctrl_register(MSTEN, 1); - set_spi_ctrl_register(SPIEN, 1); + set_spi_ctrl_register(CKPOL, false); + set_spi_ctrl_register(CKPHA, false); + set_spi_ctrl_register(MSTEN, true); + set_spi_ctrl_register(SPIEN, true); - set_spi_ctrl_register(TXINT_EN, 0); - set_spi_ctrl_register(RXINT_EN, 0); - set_spi_config_register(SPI_TX_FINISH_EN, 1); - set_spi_config_register(SPI_RX_FINISH_EN, 1); - set_spi_ctrl_register(RXOVR_EN, 0); - set_spi_ctrl_register(TXOVR_EN, 0); + set_spi_ctrl_register(TXINT_EN, false); + set_spi_ctrl_register(RXINT_EN, false); + set_spi_config_register(SPI_TX_FINISH_EN, true); + set_spi_config_register(SPI_RX_FINISH_EN, true); + set_spi_ctrl_register(RXOVR_EN, false); + set_spi_ctrl_register(TXOVR_EN, false); value = REG_READ(SPI_CTRL); value &= ~CTRL_NSSMD_3; @@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() { value = GFUNC_MODE_SPI_DMA; sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value); - set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0); + set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false); GDMA_CFG_ST en_cfg; GDMACFG_TPYES_ST init_cfg; @@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() { init_cfg.dstptr_incr = 0; init_cfg.srcptr_incr = 1; init_cfg.src_start_addr = this->dma_buf_; - init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO + init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO init_cfg.channel = SPI_TX_DMA_CHANNEL; init_cfg.prio = 0; // 10 init_cfg.u.type4.src_loop_start_addr = this->dma_buf_; @@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() { en_cfg.param = 0; sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg); - spi_dma_tx_enable(0); + spi_dma_tx_enable(false); value = REG_READ(SPI_CONFIG); value &= ~(0xFFF << 8); @@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { // protect from refreshing too often uint32_t now = micros(); - if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) { + if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 && + (now - this->last_refresh_) < *this->max_refresh_rate_) { // try again next loop iteration, so that this change won't get lost this->schedule_show(); return; @@ -293,52 +294,18 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } spi_data->first_run = false; - spi_dma_tx_enable(1); + spi_dma_tx_enable(true); this->status_clear_warning(); } light::ESPColorView BekenSPILEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -348,35 +315,12 @@ void BekenSPILEDStripLightOutput::dump_config() { "Beken SPI LED Strip:\n" " Pin: %u", this->pin_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, *this->max_refresh_rate_, this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 4ed640a3bc..1496e65d4d 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_BK72XX #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -10,16 +11,7 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - -class BekenSPILEDStripLightOutput : public light::AddressableLight { +class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; void write_state(light::LightState *state) override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -38,16 +30,13 @@ class BekenSPILEDStripLightOutput : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } - void clear_effect_data() override { for (int i = 0; i < this->size(); i++) this->effect_data_[i] = 0; @@ -58,7 +47,7 @@ class BekenSPILEDStripLightOutput : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -66,13 +55,11 @@ class BekenSPILEDStripLightOutput : public light::AddressableLight { uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_; - bool is_wrgb_; uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - RGBOrder rgb_order_; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py index 9093b08b62..5576f286f1 100644 --- a/esphome/components/beken_spi_led_strip/light.py +++ b/esphome/components/beken_spi_led_strip/light.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import libretiny, light +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB import esphome.config_validation as cv from esphome.const import ( CONF_CHIPSET, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_PIN, CONF_RGB_ORDER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["libretiny"] @@ -22,17 +24,6 @@ BekenSPILEDStripLightOutput = beken_spi_led_strip_ns.class_( "BekenSPILEDStripLightOutput", light.AddressableLight ) -RGBOrder = beken_spi_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -57,8 +48,6 @@ CHIPSETS = { } -CONF_IS_WRGB = "is_wrgb" - SUPPORTED_PINS = { libretiny.const.FAMILY_BK7231N: [16], libretiny.const.FAMILY_BK7231T: [16], @@ -67,7 +56,7 @@ SUPPORTED_PINS = { } -def _validate_pin(value): +def _validate_pin(value: int) -> int: family = libretiny.get_libretiny_family() if family not in SUPPORTED_PINS: raise cv.Invalid(f"Chip family {family} is not supported.") @@ -79,10 +68,9 @@ def _validate_pin(value): return value -def _validate_num_leds(value): - max_num_leds = 165 # 170 - if value[CONF_IS_RGBW] or value[CONF_IS_WRGB]: - max_num_leds = 123 # 127 +def _validate_num_leds(value: ConfigType) -> ConfigType: + # A white channel makes each LED one byte wider, so fewer of them fit in the DMA buffer. + max_num_leds = 123 if "W" in value[CONF_CHANNEL_COLORS] else 165 # 127 / 170 if value[CONF_NUM_LEDS] > max_num_leds: raise cv.Invalid( f"The maximum number of LEDs for this configuration is {max_num_leds}.", @@ -99,18 +87,23 @@ CONFIG_SCHEMA = cv.All( pins.internal_gpio_output_pin_number, _validate_pin ), cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, } ), + light.migrate_channel_colors( + removed_in="2027.3.0", component="beken_spi_led_strip" + ), _validate_num_leds, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) @@ -130,6 +123,6 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) 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/bh1750/sensor.py b/esphome/components/bh1750/sensor.py index 36af5aeef9..07272b3b4f 100644 --- a/esphome/components/bh1750/sensor.py +++ b/esphome/components/bh1750/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_ILLUMINANCE, STATE_CLASS_MEASUREMENT, UNIT_LUX +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@OttoWinter"] @@ -25,7 +26,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/bh1900nux/sensor.py b/esphome/components/bh1900nux/sensor.py index a70db3555a..4ddffb7940 100644 --- a/esphome/components/bh1900nux/sensor.py +++ b/esphome/components/bh1900nux/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@B48D81EFCC"] @@ -28,7 +29,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/binary/fan/__init__.py b/esphome/components/binary/fan/__init__.py index dadcf52372..03a03ec7ca 100644 --- a/esphome/components/binary/fan/__init__.py +++ b/esphome/components/binary/fan/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import fan, output import esphome.config_validation as cv from esphome.const import CONF_DIRECTION_OUTPUT, CONF_OSCILLATION_OUTPUT, CONF_OUTPUT +from esphome.types import ConfigType from .. import binary_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/binary/light/__init__.py index ebb22f4409..b6eddac341 100644 --- a/esphome/components/binary/light/__init__.py +++ b/esphome/components/binary/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv from esphome.const import CONF_OUTPUT, CONF_OUTPUT_ID +from esphome.types import ConfigType from .. import binary_ns @@ -15,7 +16,7 @@ CONFIG_SCHEMA = light.BINARY_LIGHT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) 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..1ab6f7103f 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_DELAY, @@ -448,7 +449,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({}), @@ -558,6 +561,11 @@ _CALLBACK_AUTOMATIONS = ( async def _build_binary_sensor_automations(var, config): await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK): + cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER") + if config.get(CONF_ON_MULTI_CLICK): + cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER") + for conf in config.get(CONF_ON_CLICK, []): trigger = cg.new_Pvariable( conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH] @@ -671,3 +679,15 @@ async def to_code(config): async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) + + +# automation.cpp only implements the click/double_click/multi_click triggers +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "automation.cpp": ( + "USE_BINARY_SENSOR_CLICK_TRIGGER", + "USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER", + ), + "filter.cpp": "USE_BINARY_SENSOR_FILTER", + } +) diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index b13e4a88dd..1a3c1f7536 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -1,8 +1,13 @@ +#include "esphome/core/defines.h" +#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER) + #include "automation.h" #include "esphome/core/log.h" namespace esphome::binary_sensor { +#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + static const char *const TAG = "binary_sensor.automation"; // MultiClickTrigger timeout IDs. @@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() { this->trigger(); } +#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER + +#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { if (max_length == 0) { return length >= min_length; @@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { return length >= min_length && length <= max_length; } } +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER + } // namespace esphome::binary_sensor + +#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER 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/binary_sensor_map/sensor.py b/esphome/components/binary_sensor_map/sensor.py index 965e332e28..f3133c0621 100644 --- a/esphome/components/binary_sensor_map/sensor.py +++ b/esphome/components/binary_sensor_map/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_VALUE, ICON_CHECK_CIRCLE_OUTLINE, ) +from esphome.types import ConfigType DEPENDENCIES = ["binary_sensor"] @@ -82,7 +83,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index 3ffab0f3a5..e64237c95a 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -28,6 +28,8 @@ from esphome.components.libretiny.const import ( LibreTinyComponent, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS @@ -45,25 +47,29 @@ COMPONENT_DATA = LibreTinyComponent( ) -def _set_core_data(config): +def _set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_LIBRETINY] = {} CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] = COMPONENT_DATA return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA CONFIG_SCHEMA.prepend_extra(_set_core_data) -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: return await libretiny.component_to_code(config) @pins.PIN_SCHEMA_REGISTRY.register("bk72xx", PIN_SCHEMA) -async def pin_to_code(config): +async def pin_to_code(config: ConfigType) -> MockObj: return await libretiny.gpio.component_pin_to_code(config) 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/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py new file mode 100644 index 0000000000..74b9cb5954 --- /dev/null +++ b/esphome/components/bk72xx_ble/__init__.py @@ -0,0 +1,130 @@ +"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips. + +The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack +bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build +on this component and contain no SDK calls of their own. + +Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2), +and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE +compiled in, the Beken SDK erases the bootloader flash sector at boot because +LibreTiny's partition table has no BLE bonding entry (esphome#18646, +libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in +to_code. Unknown families are capability-checked at compile time via +`__has_include("app_ble.h")`, a header only on the BLE 5.x include path +(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build +fails with a clear #error. + +No framework patch is needed: the LibreTiny beken-72xx builder already compiles +and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x; +prebuilt libble_.a per SoC). This component only calls into it via the +public ble_api.h. +""" + +import logging + +import esphome.codegen as cg +from esphome.components import libretiny +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231Q, + FAMILY_BK7231T, + FAMILY_BK7238, + FAMILY_BK7251, +) +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import EsphomeError +from esphome.types import ConfigType + +DEPENDENCIES = ["bk72xx"] +CODEOWNERS = ["@Bl00d-B0b"] + +_LOGGER = logging.getLogger(__name__) + +bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble") +BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BK72xxBLE), + # Default off: on the single-core BK72xx, bringing the BLE stack up during + # boot competes with the WiFi connection handshake. Consumers enable the + # stack lazily on first use (e.g. the tracker's first scan start). + cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT") + + +def _unsupported_family_message(family: str) -> str | None: + if family in (FAMILY_BK7231T, FAMILY_BK7251): + return ( + f"bk72xx_ble does not support {family}: this SoC has the Beken BLE 4.2 " + "stack; a BLE 5.x SoC such as BK7231N or BK7238 is required" + ) + if family == FAMILY_BK7231Q: + return "bk72xx_ble does not support BK7231Q: this SoC has no BLE" + if family == FAMILY_BK7238: + return ( + "bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK " + "erases the bootloader flash sector at boot and the device can no longer " + "start (see https://github.com/esphome/esphome/issues/18646); support " + "returns once the LibreTiny partition table fix " + "(libretiny-eu/libretiny#408) is released" + ) + return None + + +def _final_validate(config: ConfigType) -> None: + # Warn only: a hard error here would break the validate-only CI fixtures, + # which run on a BLE 4.2 board. The hard error is raised at codegen. + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + _LOGGER.warning("%s (this configuration cannot compile)", msg) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + if msg := _unsupported_family_message(libretiny.get_libretiny_family()): + raise EsphomeError(msg) + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value + # is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0 + # on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this + # SAME key — and add_platformio_option appends list values, it never replaces. + # The platform therefore skips its disable when this component is configured, + # so this =1 is the single CFG_SUPPORT_BLE define emitted. + cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"]) + + # Pin the Beken BDK release the BLE 5.x stack is validated against. The + # bundled 3.0.33 has an older BLE header/library layout — and with + # CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot + # (the reason the libretiny platform sets =0 when BLE is unused), so a + # mismatched BDK can crash the device before WiFi comes up regardless of + # enable_on_boot. Pinning here makes a plain config build against the + # validated BDK without any manual platformio_options. + _LOGGER.warning( + "bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled " + "default: the default's older BLE layout can crash the device at boot when " + "BLE is compiled in" + ) + cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78") + + # The BDK exposes the controller's BLE address as `common_default_bdaddr` on + # BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is + # derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++ + # which path is available so it doesn't reference a missing symbol. + if libretiny.get_libretiny_family() == FAMILY_BK7231N: + cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") + + cg.add_define("USE_BK72XX_BLE") diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp new file mode 100644 index 0000000000..f17f21c06b --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -0,0 +1,119 @@ +// Every SDK call the scan reconciler makes. The BDK's own start hardcodes +// passive (the active bit is commented out in both stacks), so +// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field +// the SDK's app_ble_start_scaning() except that prop takes the mode, armed +// through the SDK's own operation bookkeeping. The component pins +// beken-bdk 3.0.78; the static asserts catch a layout change on a bump. + +#include "bdk_scan.h" + +#ifdef USE_BK72XX_BLE + +// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error). +#if !defined(CLANG_TIDY) && __has_include("ble_api.h") && __has_include("app_ble.h") + +extern "C" { +#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t, + // app_ble_actv_state_get, app_ble_env_state_get, + // app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX, + // bk_ble_* (via ble_api_5_x.h) +#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send +#if __has_include("gapm_msg.h") +#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_* +#else +#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name +#endif +} + +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +// Pin the SDK surface this file depends on: a beken-bdk bump that moves these +// must fail the build, not corrupt the kernel message. +static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) && + sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4, + "beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() " + "against the SDK's app_ble_start_scaning()"); +static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX, + "beken-bdk activity sentinel changed; revalidate the scan reconciler"); +static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 && + GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5), + "beken-bdk GAPM report info changed; revalidate the tracker's demux constants"); + +bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; } + +BdkActivityState bdk_scan_state(uint8_t activity_idx) { + if (activity_idx == INVALID_ACTIVITY_IDX) + return BdkActivityState::IDLE; + switch (app_ble_actv_state_get(activity_idx)) { + case ACTV_IDLE: + return BdkActivityState::IDLE; + case ACTV_SCAN_CREATED: + return BdkActivityState::CREATED; + case ACTV_SCAN_STARTED: + return BdkActivityState::STARTED; + default: + return BdkActivityState::OTHER; + } +} + +uint8_t bdk_scan_acquire_activity() { + uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); + if (idx == INVALID_ACTIVITY_IDX) + ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); + return idx; +} + +BdkOpResult bdk_scan_create(uint8_t activity_idx) { + ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + if (ret == ERR_BLE_STATUS) + return BdkOpResult::BUSY; + ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast(ret)); + return BdkOpResult::FAILED; +} + +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) { + app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr); + struct gapm_activity_start_cmd *cmd = + KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd); + if (cmd == nullptr) { + app_ble_reset(); // the SDK's own failure path for an unsent operation + ESP_LOGE(TAG, "Scan start failed: kernel message allocation"); + return BdkOpResult::FAILED; + } + cmd->operation = GAPM_START_ACTIVITY; + cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx; + cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER; + cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0); + cmd->u_param.scan_param.scan_param_1m.scan_intv = interval; + cmd->u_param.scan_param.scan_param_1m.scan_wd = window; + cmd->u_param.scan_param.scan_param_coded.scan_intv = 0; + cmd->u_param.scan_param.scan_param_coded.scan_wd = 0; + cmd->u_param.scan_param.dup_filt_pol = 0; + cmd->u_param.scan_param.rsvd = 0; + cmd->u_param.scan_param.duration = 0; // scan until stopped + cmd->u_param.scan_param.period = 10; // matches the SDK's passive start + kernel_msg_send(cmd); + return BdkOpResult::OK; +} + +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) { + ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr); + *err_out = static_cast(ret); + if (ret == ERR_SUCCESS) + return BdkOpResult::OK; + // DEBUG on purpose: the reconciler WARNs once per streak and the stuck + // ERROR carries this code — a per-retry ERROR would be unbounded. + ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast(ret)); + return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // !CLANG_TIDY && ble_api.h && app_ble.h +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bdk_scan.h b/esphome/components/bk72xx_ble/bdk_scan.h new file mode 100644 index 0000000000..47bce2449d --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.h @@ -0,0 +1,51 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include + +namespace esphome::bk72xx_ble { + +/// Activity index value marking "no scan activity", the BDK's own convention +/// (asserted against its symbol in bdk_scan.cpp). +inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF; + +/// Scan-relevant controller activity states, read live from the SDK. +enum class BdkActivityState : uint8_t { + IDLE, ///< No activity (or one whose create failed). + CREATED, ///< Created but not started. + STARTED, ///< Scanning. + OTHER, ///< A non-scan or transitional state; settles on a later read. +}; + +/// Outcome of a BDK scan operation request. +enum class BdkOpResult : uint8_t { + OK, ///< Accepted; completion is asynchronous. + BUSY, ///< Another controller operation is in flight; retry later. + FAILED, ///< Rejected. +}; + +/// True when no controller operation is in flight (APP_BLE_READY). +bool bdk_scan_ready(); +/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE. +BdkActivityState bdk_scan_state(uint8_t activity_idx); +/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free. +uint8_t bdk_scan_acquire_activity(); +/// Create the scan activity (asynchronous); started once CREATED is observed. +BdkOpResult bdk_scan_create(uint8_t activity_idx); +/// Start a created activity: the packed GAPM start, taking the scan mode the +/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the +/// kernel message could not be allocated (the armed SDK operation is rolled +/// back). +BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active); +/// Release the activity: delete when never started (a stop would be +/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED +/// on any other error; err_out receives the SDK code (0 on success). +/// Teardown is asynchronous — observe IDLE to confirm. +BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out); + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp new file mode 100644 index 0000000000..52401114e6 --- /dev/null +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -0,0 +1,519 @@ +// bk72xx_ble.cpp +// +// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx +// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that +// talks to the Beken BDK BLE stack: +// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), +// - the controller BLE address, +// - the scan reconciler (request, pacing, bring-up budget) over the +// bdk_scan surface, +// - the scan-report ring: the BDK notice callback (BLE task) takes a report +// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains, +// dispatches on the main task and returns reports to the pool — the same +// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at +// steady state. +// Consumers contain no SDK calls of their own. +// +// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny +// beken-72xx builder itself (prebuilt libble_.a + ble_5_x sources, gated +// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only +// calls into it via the public ble_api.h — no framework patch is required. + +#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE + +#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release) + +#ifdef USE_BK72XX_BLE + +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" // get_mac_address_raw() +#include "esphome/core/log.h" + +// --------------------------------------------------------------------------- +// SDK-capability gate (not a chip allowlist). +// This component drives the Beken BLE *5.x* controller. `ble_api.h` cannot be +// the probe: it ships for every SoC (driver/include) and merely switches on +// CFG_BLE_VERSION internally. `app_ble.h` is on the include path only when the +// LibreTiny beken-72xx builder selects a 5.x stack, so gating on it supports +// any BLE-5.x chip — present or future — without a hard-coded list, and a +// non-5.x build fails here with a clear message instead of a cryptic +// "app_ble.h: No such file or directory". +// --------------------------------------------------------------------------- +#if defined(CLANG_TIDY) +// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API +// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing +// accurate to analyze the SDK calls against — skip the file under analysis. +#define BK72XX_BLE_NO_SDK +#elif !__has_include("ble_api.h") || !__has_include("app_ble.h") +// Also skip the SDK body: #error does not stop the preprocessor, and on a 4.2 +// SoC ble_api.h exists, so without the guard the 5.x symbols would fail one by +// one and bury this message. +#define BK72XX_BLE_NO_SDK +#error \ + "bk72xx_ble requires a BLE 5.x Beken SDK (app_ble.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported." +#endif + +#ifndef BK72XX_BLE_NO_SDK + +// --------------------------------------------------------------------------- +// Beken BDK BLE 5.x SDK — public API. +// Exposed on the include path by the LibreTiny beken-72xx builder +// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these +// are C headers consumed from C++ (a standard C-header-from-C++ pattern). +// --------------------------------------------------------------------------- +extern "C" { +#include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t, + // BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp) +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR +#include "common_bt_defines.h" // struct bd_addr +// The controller's public BLE address, populated by the BDK during ble_entry(). +// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the +// address is derived from the WiFi MAC instead (matching the BDK's own fallback). +extern struct bd_addr common_default_bdaddr; +#endif +// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so +// declare it here. +void ble_entry(void); +} + +namespace esphome::bk72xx_ble { + +static const char *const TAG = "bk72xx_ble"; + +static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops +static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release +static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED +static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence +static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED) + +// The BDK notice callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// --------------------------------------------------------------------------- +// BLE notice callback — runs in the BDK BLE task context. +// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice +// carrying a recv_adv_t. Copy it into the queue and return; all dispatch +// happens in loop() on the main task. +// --------------------------------------------------------------------------- +static void ble_notice_callback(ble_notice_t notice, void *param) { + if (s_ble == nullptr || param == nullptr) + return; + if (notice != BLE_5_REPORT_ADV) + return; + + const recv_adv_t *info = reinterpret_cast(param); + // rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for + // a signed dBm value packed in a uint8_t). + s_ble->enqueue_scan_report(info->adv_addr, static_cast(info->rssi), info->adv_addr_type, + static_cast(info->evt_type), info->data, info->data_len); +} + +void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, + const uint8_t *data, uint16_t data_len) { + BLEScanReport *report = this->report_pool_.allocate(); + if (report == nullptr) { + // Pool exhausted — the queue is full; count and drop. + this->report_queue_.increment_dropped_count(); + return; + } + memcpy(report->mac, mac, MAC_ADDRESS_SIZE); + report->rssi = rssi; + report->addr_type = addr_type; + report->evt_type = evt_type; + report->data_len = + (data_len <= sizeof(report->data)) ? static_cast(data_len) : static_cast(sizeof(report->data)); + memcpy(report->data, data, report->data_len); + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void BK72xxBLE::setup() { + s_ble = this; + // The report pool grows lazily on purpose: the BDK notice callback runs in + // task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic + // stays far below the pool cap, so not warming contains RAM. + // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before + // the stack is up (it is re-read once ble_entry() has run). + this->resolve_mac_(); + if (this->enable_on_boot_) { + this->enable(); + } +} + +// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK +// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order). +float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; } + +void BK72xxBLE::enable() { + if (this->state_ != BLEComponentState::STATE_OFF) + return; + this->state_ = BLEComponentState::ENABLING; + + // One-time BLE stack init: register the notice callback, then bring up the + // BDK BLE stack. The BDK has no teardown path — init happens at most once. + ble_set_notice_cb(ble_notice_callback); + ble_entry(); + + delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time + + // Re-read the BLE MAC now that the controller is up (common_default_bdaddr is + // populated by ble_entry()); resolve_mac_() may have fallen back earlier. + this->resolve_mac_(); + +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR + // Liveness heuristic (BK7231N): a healthy ble_entry() populates + // common_default_bdaddr during init, so all-zero after the settle delay + // suggests the stack did not come up. The BDK entry point returns void — no + // return code exists — so warn rather than fail: scan starts against a dead + // stack already fail cleanly downstream (no idle activity handle). + bool bdaddr_live = false; + for (uint8_t b : common_default_bdaddr.addr) { + if (b != 0) { + bdaddr_live = true; + break; + } + } + if (!bdaddr_live) + ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started"); +#endif + + this->state_ = BLEComponentState::ACTIVE; + ESP_LOGD(TAG, "BLE stack initialised"); +} + +void BK72xxBLE::loop() { + // Keep reconciling toward the requested scan state (e.g. complete a stop + // that arrived while a controller operation was in flight), and re-check a + // settled scan at low frequency: a controller-side drop re-enters the + // bring-up, and the budget's FAILED feeds the tracker's recovery. + // Keep driving until settled: any PENDING, plus a terminal stop whose slot + // must still be freed. A FAILED scan request is the one combination not + // re-driven here — that belongs to the tracker's backoff. + const uint32_t pump_now = App.get_loop_component_start_time(); + if (this->last_result_ == ScanOpResult::PENDING || + (!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) { + const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED) + ? RECONCILE_REJECTED_RETRY_MS + : RECONCILE_RETRY_MS; + if (pump_now - this->last_advance_ms_ >= gate) + this->advance_(); + } else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED && + pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) { + // Re-check a settled scan; scan_start() refills the bring-up budget. + // WARN: the only report of a drop that recovers inside its budget. + if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) != + ScanOpResult::SETTLED) + ESP_LOGW(TAG, "Controller dropped the scan; restarting"); + } + + // Drain the lock-free ring filled by the BLE task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report == nullptr) + return; + do { +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + + // Log dropped reports — only reachable when reports were processed; drops can + // only occur while the queue is full, and only this loop drains it. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); +} + +void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + for (int i = 0; i < 6; i++) + out[i] = this->ble_mac_[i]; +} + +void BK72xxBLE::dump_config() { + // ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the + // MSB-first order Home Assistant shows. + ESP_LOGCONFIG(TAG, + "BK72xx BLE:\n" + " MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n" + " Active: %s", + this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1], + this->ble_mac_[0], YESNO(this->is_active())); +} + +// --------------------------------------------------------------------------- +// MAC resolution +// --------------------------------------------------------------------------- + +void BK72xxBLE::resolve_mac_() { +#ifdef BK72XX_BLE_HAS_COMMON_BDADDR + // BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention) + // during ble_entry(). It may still be zero before the stack is up; if so, fall + // through to the WiFi-derived MAC below. + bool nonzero = false; + for (uint8_t b : common_default_bdaddr.addr) { + if (b != 0) { + nonzero = true; + break; + } + } + if (nonzero) { + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); + return; + } +#endif + // Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other + // BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the + // Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte + // incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next + // byte), OUI unchanged. This reproduces the address the controller advertises with + // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it + // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment + // would carry differently. + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; + get_mac_address_raw(wifi_mac); // MSB-first + const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + // Store LSB-first to match recv_adv_t adv_addr ordering. + for (int i = 0; i < 6; i++) + this->ble_mac_[i] = ble[5 - i]; +} + +// --------------------------------------------------------------------------- +// Scan reconciler +// --------------------------------------------------------------------------- + +// Episode boundary: fresh teardown deadline and error bookkeeping. +void BK72xxBLE::reset_teardown_episode_() { + this->teardown_since_ms_ = 0; + this->restarting_ = false; + this->last_release_err_ = 0; +} + +ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) + this->enable(); + + const ScanParams params{active, interval, window}; + // A new episode refills the budget and gets a fresh teardown deadline; a + // re-call observing an in-flight bring-up (last result PENDING) must not. + if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) { + this->pending_since_ms_ = App.get_loop_component_start_time(); + this->reset_teardown_episode_(); + } + this->scan_wanted_ = true; + this->requested_ = params; + return this->advance_(); +} + +void BK72xxBLE::scan_stop() { + if (this->scan_wanted_) { + // A stamp inherited from a stuck restart would fail the stop on its + // first advance. + this->reset_teardown_episode_(); + } + this->scan_wanted_ = false; + this->advance_(); +} + +bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) { + // millis() on both sides: the loop clock is frozen while this blocks. + const uint32_t start = millis(); + while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) { + if (millis() - start >= timeout_ms) + return false; + delay(RECONCILE_RETRY_MS); + this->advance_(); + } + return this->last_result_ == ScanOpResult::SETTLED; +} + +// Teardown is asynchronous: the handle is kept until an IDLE observation +// confirms the radio is idle. A rejection WARNs once per failure streak and +// widens the pump gate; the epilogue owns the stuck-teardown deadline. +void BK72xxBLE::release_activity_(BdkActivityState state) { + const BdkOpResult result = + bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_); + if (result == BdkOpResult::OK) { + this->release_warned_ = false; + return; + } + if (!this->release_warned_) { + // A hard error carries its code immediately; the 30 s stuck ERROR follows + // if it persists. + if (result == BdkOpResult::FAILED) { + ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_); + } else { + ESP_LOGW(TAG, "Scan activity release rejected; retrying"); + } + this->release_warned_ = true; + } +} + +// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged +// each interval) and report stuck. +bool BK72xxBLE::teardown_stuck_(uint32_t now) { + if (this->teardown_since_ms_ == 0) { + this->teardown_since_ms_ = now; + this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline + return false; + } + if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS) + return false; + if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) { + if (this->last_release_err_ != 0) { + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_); + } else { + // No rejected release this episode: stuck waiting on the controller. + ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)"); + } + this->teardown_stuck_log_ms_ = now; + } + return true; +} + +// One SDK operation per call toward the latched request; controller state is +// read live each time (it changes on the BLE task, so nothing is mirrored). +// The epilogue owns all deadlines and episode bookkeeping. +ScanOpResult BK72xxBLE::advance_() { + if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + // Nothing to do; also keeps SDK reads off the pre-enable() path. + this->last_result_ = ScanOpResult::SETTLED; + return ScanOpResult::SETTLED; + } + const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_); + const bool ready = bdk_scan_ready(); + ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready); + + const uint32_t now = App.get_loop_component_start_time(); + this->last_advance_ms_ = now; + if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) { + // Any teardown episode is over (IDLE observed with the controller + // settled, or e.g. a mode flip that settled back without ever reaching + // IDLE). An IDLE read while an operation is in flight proves nothing — + // a stop deferred there must keep its episode running. + this->reset_teardown_episode_(); + this->release_warned_ = false; + } + if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) { + // The mode-change release is observed complete; the rest is a normal + // bring-up on a fresh budget. + this->restarting_ = false; + this->pending_since_ms_ = now; + } + // Not chained to the clear above: a bring-up waiting at IDLE (create still + // in flight) must keep spending its budget. + if (result == ScanOpResult::PENDING) { + if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) { + // A downed radio spends the bring-up budget; exhausting it hands + // recovery to the tracker's backoff. + if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) { + ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start"); + result = ScanOpResult::FAILED; + } + } else { + // A teardown is pending: a stop, or a mode-change release still in + // flight (restarting_); either way the bring-up budget waits. + if (this->scan_wanted_) + this->pending_since_ms_ = now; + if (this->teardown_stuck_(now)) { + // Terminal for stop AND restart: the tracker's backoff owns recovery + // (a stop's release keeps re-driving from loop(); a restart is + // re-requested through scan_start() with a fresh deadline). + result = ScanOpResult::FAILED; + } + } + } + this->last_result_ = result; + return result; +} + +ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::IDLE && ready) { + // Fully torn down (or never created): the radio is idle. IDLE is trusted + // only when the controller is settled — mid-create the slot still reads + // IDLE, and dropping the handle then would leak the activity once the + // create lands. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::SETTLED; + } + if (!ready) { + // Acting mid-operation could delete an activity whose start lands + // afterwards, leaking the slot with the radio on; wait. + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan stop deferred (controller busy)"); + return ScanOpResult::PENDING; + } + // Settled, so CREATED unambiguously means "never started". + this->release_activity_(state); + return ScanOpResult::PENDING; // confirmed once IDLE is observed +} + +ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) { + if (state == BdkActivityState::STARTED) { + if (this->applied_ == this->requested_) + return ScanOpResult::SETTLED; + // Running with different mode or parameters: tear down (the SDK stop + // chain also deletes the activity) and recreate on a later advance. + if (ready) { + this->release_activity_(state); + // Invalidate so a flip back to the old params cannot SETTLE against the + // activity being deleted (interval 0 never matches a real request). + this->applied_.interval = 0; + this->restarting_ = true; + } + return ScanOpResult::PENDING; + } + if (!ready) { + if (this->last_result_ == ScanOpResult::SETTLED) + ESP_LOGD(TAG, "Scan start deferred (controller busy)"); + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::CREATED) { + // Fire-and-forget: SETTLED only once a later advance observes the scan + // running, so a rejected start is retried rather than silently dead. On + // failure the created activity is intact; keep the handle. + if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window, + this->requested_.active) != BdkOpResult::OK) + return ScanOpResult::FAILED; + this->applied_ = this->requested_; + return ScanOpResult::PENDING; + } + if (state == BdkActivityState::OTHER) + return ScanOpResult::PENDING; // transitional; settles on a later read + + // IDLE and ready: acquire a slot and create. A kept index is deliberately + // reused: SDK delete returns the slot to idle and create requires an idle + // slot, so it equals a fresh acquire — while clearing here would orphan a + // create still in flight (the BUSY race below). + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) { + this->scan_activity_idx_ = bdk_scan_acquire_activity(); + if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) + return ScanOpResult::FAILED; + } + switch (bdk_scan_create(this->scan_activity_idx_)) { + case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot + case BdkOpResult::OK: + return ScanOpResult::PENDING; + case BdkOpResult::FAILED: + break; + } + // Safe to clear (unlike BUSY): acquire is a pure search, so a rejected + // create leaves the slot IDLE for re-acquire. + this->scan_activity_idx_ = INVALID_ACTIVITY_IDX; + return ScanOpResult::FAILED; +} + +} // namespace esphome::bk72xx_ble + +#endif // BK72XX_BLE_NO_SDK +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h new file mode 100644 index 0000000000..687fd396e4 --- /dev/null +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -0,0 +1,158 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BK72XX_BLE + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include + +#include "bdk_scan.h" + +namespace esphome::bk72xx_ble { + +enum class BLEComponentState : uint8_t { + STATE_OFF = 0, + ENABLING, + ACTIVE, +}; + +/// Outcome of one reconciliation step. +enum class ScanOpResult : uint8_t { + SETTLED, ///< The request is reached: scan observed running, or stopped + ///< with the activity fully released. + PENDING, ///< A step is in flight; loop() keeps advancing — call + ///< scan_start() again to learn the outcome. + FAILED, ///< The controller rejected a step; retry later. +}; + +/// One scan request: mode plus timing, in BLE units (0.625 ms). +struct ScanParams { + bool active; + uint16_t interval; + uint16_t window; + bool operator==(const ScanParams &) const = default; +}; + +/// One advertisement report from the controller. +struct BLEScanReport { + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm + uint8_t addr_type; + // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type + // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the + // tracker's merger tell the two frames apart. + uint8_t evt_type; + uint8_t data_len; // bytes valid in data[] + uint8_t data[62]; // legacy advertisement (31) + scan response (31) + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always runs +/// on the ESPHome main task: reports are queued from the BDK BLE task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// state (the esp32_ble event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the BLE task and loop(). +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + +class BK72xxBLE final : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + /// Bring up the BDK BLE stack (one-time; the BDK has no teardown path). + void enable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + /// Controller BLE address, least-significant octet first (BLE convention). + void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; + +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits BK72XX_BLE_SCAN_LISTENER_COUNT. + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif + + /// Request a scan (interval/window in 0.625 ms BLE units); enables the + /// stack first if needed. PENDING until the scan is observed running — + /// loop() keeps advancing, call again to learn the outcome. + ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active); + /// Request the scanner stopped and the activity released; steps that + /// cannot run yet are completed from loop(). + void scan_stop(); + /// Drive a requested stop until the radio is observed idle, bounded by + /// timeout_ms (for OTA). Returns false if it still has not settled. + bool flush_pending_stop(uint32_t timeout_ms); + /// Last reconciliation outcome; on FAILED the consumer's retry policy owns + /// recovery. + ScanOpResult last_scan_result() const { return this->last_result_; } + + /// Internal: buffer one controller report (BDK notice callback, BLE task + /// context — bounded copy under the scheduler lock, nothing else). + void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data, + uint16_t data_len); + + protected: + void resolve_mac_(); + ScanOpResult advance_(); + ScanOpResult advance_stop_(BdkActivityState state, bool ready); + ScanOpResult advance_start_(BdkActivityState state, bool ready); + bool teardown_stuck_(uint32_t now); + void reset_teardown_episode_(); + void release_activity_(BdkActivityState state); + +#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the BDK notice callback (BLE task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; + // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. + uint32_t last_advance_ms_{0}; + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention) + uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; + bool scan_wanted_{false}; // the latched request is to scan (vs stopped) + bool release_warned_{false}; // gates the release WARN; widens the pump gate + bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released + bool enable_on_boot_{false}; + // PENDING means advance_() has more to do; loop() drives it, paced and + // (for a bring-up) bounded. + ScanOpResult last_result_{ScanOpResult::SETTLED}; + BLEComponentState state_{BLEComponentState::STATE_OFF}; +}; + +} // namespace esphome::bk72xx_ble + +#endif // USE_BK72XX_BLE diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py new file mode 100644 index 0000000000..96b3536601 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -0,0 +1,190 @@ +"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable +LibreTiny Beken chips (beken-72xx family). + +Builds on the bk72xx_ble controller component (stack bring-up, BLE address, +scan primitives) and implements the platform-neutral ble_device_base BLEHub +contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner, +bthome_mithermometer, xiaomi_*, …) bind to this tracker through +cv.use_id(BLEHub) with no BK-specific code. + +Scan modes: + continuous: true — scan runs forever; never stops automatically. + Use this when the radio is dedicated to BLE. + continuous: false — a started scan runs for `duration` ms, then stops. The + FIRST start is external too: nothing in this component + starts a non-continuous scan on boot — the radio stays + idle until bk72xx_ble_tracker.start_scan fires (e.g. + from an api client-connected automation), so the + single-core radio can service WiFi in between scans. +""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import bk72xx_ble, ble_device_base, ota +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, +) +from esphome.core import ID +from esphome.types import ConfigType + +CONF_BK72XX_BLE_ID = "bk72xx_ble_id" + +DEPENDENCIES = ["bk72xx"] +AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] +CODEOWNERS = ["@Bl00d-B0b"] + +ble_device_base.register_hub_provider("bk72xx_ble_tracker") + +bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker") +BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( + "BK72xxBLETracker", ble_device_base.BLEHub, cg.Component +) + +StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + + +# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms +# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in +# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.) +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms") + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(BK72xxBLETracker), + cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +@automation.register_action( + "bk72xx_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + # Optional with no default, unlike esp32_ble_tracker: omitting it + # keeps whatever scan_parameters.continuous configured, instead of + # silently forcing one-shot. + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "bk72xx_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(BK72xxBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_BK72XX_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the BDK delivers the pair + # as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) + cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + bk72xx_ble.request_scan_listener_slot() + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/bk72xx_ble_tracker/automation.h b/esphome/components/bk72xx_ble_tracker/automation.h new file mode 100644 index 0000000000..9017d19d71 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/automation.h @@ -0,0 +1,48 @@ +// Automation triggers and actions for bk72xx_ble_tracker: triggers are the +// neutral ble_device_base classes; only the scan-control actions are +// platform-specific. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::bk72xx_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp new file mode 100644 index 0000000000..1b4e6245ae --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -0,0 +1,378 @@ +// bk72xx_ble_tracker.cpp +// +// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period +// timers and the rate-limited start retry. All controller access (stack +// bring-up, scan primitives, the BLE-task → main-task report queue) goes +// through the bk72xx_ble component — no SDK calls and no cross-task state here. + +#ifdef USE_LIBRETINY + +#include "bk72xx_ble_tracker.h" + +#include + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::bk72xx_ble_tracker { + +static const char *const TAG = "bk72xx_ble_tracker"; + +// Minimum interval between scan (re)start attempts, so a failing controller start +// cannot be retried every main-loop iteration (single-core CPU starvation). The +// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller +// that never comes up — the controller logs each failure at ERROR — settles into a +// slow, quiet poll instead of an error line every second for the rest of uptime; +// a single WARN is emitted when the retry interval first saturates. +static constexpr uint32_t SCAN_START_RETRY_MS = 1000; +static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s +// Stable-run time before the failure streak clears; reset-on-start would keep +// a flapping controller at the 1 s gate. +static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000; + +// Radio-idle deadline for the bounded stop drain at OTA start. +static constexpr uint32_t OTA_STOP_FLUSH_MS = 100; + +// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part. +constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; } + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // BLE task and delivers here on the main task. + this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; unclaimed + // devices are logged only on one-shot scans (continuous would spam). + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — on the single-core BK72xx the + // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + // scan_requested_ check: an on_boot start_scan latched before this setup() + // must keep the retry loop running (rp2/ln882h parity). + if (!this->scan_continuous_ && !this->scan_requested_) { + // Nothing to time until an explicit start_scan(); it re-enables the loop. + this->disable_loop(); + } +} + +#ifdef USE_OTA_STATE_LISTENER +void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_requested_before_ota_ = this->scan_requested_; + this->stop_scan(); + // The transfer starves the loop; a deferred stop would leave the radio + // scanning for the whole update, so drain it here, bounded. + if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS)) + ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update"); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted update; + // loop() restarts the scan on its next iteration (continuous idle branch). + if (this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() parked it + } + // A one-shot request that was still pending (latched, retrying) when the + // OTA paused scanning is re-latched, not dropped — loop() resumes the retry. + if (this->scan_requested_before_ota_) { + this->scan_requested_before_ota_ = false; + this->scan_requested_ = true; + this->enable_loop(); + } + } +} +#endif // USE_OTA_STATE_LISTENER + +void BK72xxBLETracker::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. + if (!this->merger_.empty()) + this->merger_.sweep(now); + + // Before the drop branch: a drop after a stable run starts a fresh streak. + if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS) + this->failed_start_count_ = 0; + + // A terminal failure while we report running recovers via the normal retry + // path; the drop charges the backoff so a flapping controller escalates. + if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) { + ESP_LOGW(TAG, "Controller scan lost; retrying"); + this->scan_requested_ = true; + this->count_failed_start_(); + this->mark_scan_ended_(now); + } + + if (this->scan_continuous_) { + if (!this->scan_running_) { + // One-iteration deferral; all stamps share this iteration's cached + // timestamp, so the period check below cannot underflow. + if (this->try_start_with_backoff_(now)) + return; + } + // Period timer: fire on_scan_end() once per scan_duration_ window, mirroring + // esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan + // that never came up (start kept failing) does not fire spurious on_scan_end events. + if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) { + this->fire_scan_end_(); + this->scan_period_start_ = now; + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. api: on_client_connected:). + // + // A requested start that failed (same controller failures the continuous branch + // absorbs) is retried with the same backoff — otherwise a failed one-shot start + // would be silent: the scan never runs, stop_scan_() is never reached and + // on_scan_end() never fires, leaving period-keyed consumers waiting forever. + if (this->scan_requested_ && !this->scan_running_) { + // Same one-iteration deferral as the continuous branch. + if (this->try_start_with_backoff_(now)) + return; + } + if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + // A full-duration run proves the controller healthy even when duration is + // shorter than SCAN_STABLE_RESET_MS. + this->failed_start_count_ = 0; + this->stop_scan_(); + } +} + +bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) { + // Rate-limit (re)start attempts. The controller start can fail (no idle activity + // handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every + // main-loop iteration would spin the single-core CPU and starve WiFi (device + // becomes unresponsive). The interval backs off with consecutive failures so a + // controller that never comes up polls slowly and quietly. + // + // force bypasses the gate for an explicit user start (start_scan()) — but + // only while the failure streak is clean. Once the controller is failing, + // even user-initiated attempts respect the backoff, so a start_scan() action + // on a short cadence cannot hammer a failing controller; the attempt stays + // inside the failure accounting below either way. + // Mid bring-up, observe instead of re-issuing (the hub self-advances). A + // SETTLED outcome completes immediately; only fresh attempts after FAILED + // are rate-limited. + const auto hub = this->parent_->last_scan_result(); + if (hub == bk72xx_ble::ScanOpResult::PENDING) + return false; + if (hub == bk72xx_ble::ScanOpResult::FAILED) { + if (this->start_attempt_open_) { + // Our bring-up gave up asynchronously; charge it to the backoff. + this->start_attempt_open_ = false; + this->count_failed_start_(); + } + if ((!force || this->failed_start_count_ != 0) && + now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_)) + return false; + } + this->start_scan_(); + if (!this->scan_running_) { + if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) { + this->start_attempt_open_ = true; + return false; // the controller is still bringing the scan up; not a failure + } + this->count_failed_start_(); + } + return this->scan_running_; +} + +void BK72xxBLETracker::count_failed_start_() { + if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) { + ++this->failed_start_count_; + if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) { + ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s", + (SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000); + } + } +} + +void BK72xxBLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "BK72xx BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n" + " Scan Type: %s (configured %s)\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_, + ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); +} + +// --------------------------------------------------------------------------- +// Scan report — delivered by the controller's loop() on the ESPHome main task +// (the controller queues reports from the BLE task), so publish_state() and +// listener dispatch run in main-loop context with no cross-task handling here. +// --------------------------------------------------------------------------- + +// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type, +// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and +// 5.2 fill it from gapm_ext_adv_report_ind.info). +static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5; + +// Demux advertisements vs scan responses into the shared merger: the BDK +// delivers the pair as separate reports; a scannable advertisement is held +// until its scan response arrives and delivered as one merged frame. +void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { + const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK; + if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; + } + // Stash only while an active scan runs: a passive scan never gets a + // response, and after a stop nothing would sweep the merger, so a late + // report would surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + App.get_loop_component_start_time()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); +} + +// --------------------------------------------------------------------------- +// Public scan control +// --------------------------------------------------------------------------- + +void BK72xxBLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + // + // Nothing to do while a scan is already running: latching here would leave + // scan_requested_ set after that scan ends and silently restart a one-shot + // scan nobody asked for. + if (this->scan_running_) + return; + + // The request is latched: if this immediate attempt fails (controller busy, + // WiFi/BLE coexistence), loop() keeps retrying it with backoff even in + // non-continuous mode, so a one-shot start cannot fail silently. + // + // Routed through the backoff helper (forced: the user asked for an immediate + // attempt) so a failure here still counts toward the backoff escalation and + // its WARN. The force bypass only applies while the failure streak is clean — + // against a failing controller, repeated start_scan() calls are rate-limited + // like any other attempt. + this->scan_requested_ = true; + this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_() + this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true); +} + +void BK72xxBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = App.get_loop_component_start_time(); +} + +void BK72xxBLETracker::stop_scan() { + this->scan_continuous_ = false; + this->scan_requested_ = false; // also cancels a pending (not yet successful) start + this->stop_scan_(); +} + +// --------------------------------------------------------------------------- +// Internal scan start / stop +// --------------------------------------------------------------------------- + +bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() { + this->last_scan_start_attempt_ = App.get_loop_component_start_time(); + return this->parent_->scan_start(static_cast(this->scan_interval_), + static_cast(this->scan_window_), this->scan_active_); +} + +void BK72xxBLETracker::start_scan_() { + if (this->scan_running_) + return; + + if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED) + return; + + const uint32_t now = App.get_loop_component_start_time(); + this->scan_running_ = true; + this->scan_requested_ = false; // the latched one-shot request is satisfied + this->start_attempt_open_ = false; + // failed_start_count_ deliberately not reset here; only a stable run clears it (loop()). + this->scan_start_time_ = now; + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)", + this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_), + ble_units_to_ms(this->scan_interval_)); + // Re-anchor the on_scan_end period to every successful start — first start (so the + // period counts from the scan, not from boot) and every restart after a stop (so + // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous + // mode 10 minutes later, does not fire on_scan_end before an advertisement can + // arrive). scan_started_once_ purely gates the period timer. + this->scan_period_start_ = now; + this->scan_started_once_ = true; +} + +// Deliberate logical/physical split: on_scan_end() reports the tracker's +// intent while the hub winds the radio down asynchronously; OTA is the one +// path that must wait, and it flushes explicitly. +void BK72xxBLETracker::stop_scan_() { + this->start_attempt_open_ = false; // an abandoned bring-up is not charged + this->parent_->scan_stop(); // idempotent: releases whatever the hub holds + if (this->scan_running_) { + ESP_LOGD(TAG, "Scan stopped"); + this->mark_scan_ended_(App.get_loop_component_start_time()); + } + // Park when idle (the hub drives its own teardown); re-check because an + // on_scan_end automation may have restarted the scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_) + this->disable_loop(); +} + +// The period re-anchor keeps on_scan_end from double-firing in one iteration. +void BK72xxBLETracker::mark_scan_ended_(uint32_t now) { + this->scan_running_ = false; + this->fire_scan_end_(); + this->scan_period_start_ = now; +} + +void BK72xxBLETracker::fire_scan_end_() { + // Deliver held advertisements whose scan response never came (unmerged) + // BEFORE on_scan_end fires. + this->merger_.flush(); + this->dispatcher_.on_scan_end(); +} + +// true = request latched, not applied: the reconciler applies it +// asynchronously and loop() recovers a failed re-arm (ln882h parity). +bool BK72xxBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); + // The controller reconciler restarts a running scan itself; the scan stays + // logically running. An idle scanner picks the mode up on its next start. + if (this->scan_running_) + this->controller_scan_start_(); + return true; +} + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h new file mode 100644 index 0000000000..2334cfe414 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -0,0 +1,182 @@ +// bk72xx_ble_tracker.h +// +// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family). +// Implements the platform-neutral ble_device_base::BLEHub contract on top of the +// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered +// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the +// hub's raw-advertisement callback. +// +// This component contains no Beken SDK calls and no cross-task state: the +// controller (stack bring-up, BLE address, scan primitives, and the BLE-task → +// main-task report queue) is owned by bk72xx_ble, which delivers every scan +// report on the ESPHome main task. The tracker owns scan policy — parameters, +// duration/period timers and the rate-limited start retry. +// +// YAML config (values shown are the defaults; interval/window are a 30 % duty +// cycle, the BK reference scan rate): +// +// bk72xx_ble_tracker: +// scan_parameters: +// interval: 100ms +// window: 30ms +// duration: 5min +// continuous: true +// active: true + +#pragma once + +#ifdef USE_LIBRETINY + +#include "esphome/components/bk72xx_ble/bk72xx_ble.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::bk72xx_ble_tracker { + +// --------------------------------------------------------------------------- +// BK72xxBLETracker +// --------------------------------------------------------------------------- + +class BK72xxBLETracker : public Component, + public bk72xx_ble::BLEScanListener, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention); + // mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.active); runtime mode requests change + /// only the resolved mode. + void set_scan_active(bool scan_active) { + this->scan_active_ = scan_active; + this->scan_active_configured_ = scan_active; + } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { + this->dispatcher_.register_listener(listener); + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->dispatcher_.set_raw_advertisement_callback(callback); + } + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // Active scanning is driven through bk72xx_ble's reconciler because the BDK + // API itself is passive-only. The controller delivers scan responses as + // separate reports; this tracker merges the pair before delivery (shared + // ScanResponseMerger, Bluedroid semantics). No GATT client. + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; + } + bool request_scan_mode(bool active); + // The controller stores the address LSB-first (BLE convention); the contract + // wants printable (MSB-first) order. + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; + this->parent_->get_mac_lsb_first(mac); + for (int i = 0; i < 6; i++) + out[i] = mac[5 - i]; + } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + + // ---- bk72xx_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main task — the + // BLE-task → main-task handoff already happened in the controller's queue. + void on_scan_report(const bk72xx_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + void stop_scan_(); + void fire_scan_end_(); + void mark_scan_ended_(uint32_t now); + /// Stamp-and-start for every controller scan attempt, so the retry rate + /// limit covers all callers. + bk72xx_ble::ScanOpResult controller_scan_start_(); + /// Rate-limited (re)start; true when the scan is running (the caller must + /// not reuse a `now` older than the stamps this refreshed). Force and + /// backoff rules are documented at the definition. + bool try_start_with_backoff_(uint32_t now, bool force = false); + void count_failed_start_(); + + bool scan_running_{false}; + bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff + bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once + // Defaults: the BK reference — 30 % duty cycle + // (interval 100 ms / window 30 ms), in 0.625 ms BLE units. + uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms + uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %) + uint32_t scan_duration_{300000}; + bool scan_continuous_{true}; + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it + bool scan_active_{true}; // resolved mode; see scan_parameters.active + bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure +#endif + uint32_t scan_start_time_{0}; + + uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries + uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop()) + uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end() + bool scan_started_once_{false}; // true after first successful scan start; gates the period timer + + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks). Merger clock: stash_adv() reads the PARENT's cached loop time + // (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this + // component's — same App.loop() pass, so the delta stays non-negative and + // the 300 ms timeout holds. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; +}; + +} // namespace esphome::bk72xx_ble_tracker + +#endif // USE_LIBRETINY 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/bl0906/sensor.py b/esphome/components/bl0906/sensor.py index 059e10e962..1a0c2287ab 100644 --- a/esphome/components/bl0906/sensor.py +++ b/esphome/components/bl0906/sensor.py @@ -32,6 +32,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType # Import ICONS not included in esphome's const.py, from the local components const.py from .const import ICON_ENERGY, ICON_FREQUENCY, ICON_VOLTAGE @@ -145,13 +148,18 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ), synchronous=True, ) -async def reset_energy_to_code(config, action_id, template_arg, args): +async def reset_energy_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/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/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index bd4bdd93e5..ec17ef2c7e 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -88,7 +89,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/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/__init__.py b/esphome/components/bl0940/button/__init__.py index 04d11e6e30..e87a647392 100644 --- a/esphome/components/bl0940/button/__init__.py +++ b/esphome/components/bl0940/button/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await button.new_button(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_BL0940_ID]) 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/__init__.py b/esphome/components/bl0940/number/__init__.py index 92ab2837b3..b5a66e682a 100644 --- a/esphome/components/bl0940/number/__init__.py +++ b/esphome/components/bl0940/number/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, UNIT_PERCENT, ) +from esphome.types import ConfigType from .. import CONF_BL0940_ID, bl0940_ns from ..sensor import BL0940 @@ -27,7 +28,7 @@ CalibrationNumber = bl0940_ns.class_( ) -def validate_min_max(config): +def validate_min_max(config: ConfigType) -> ConfigType: if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: raise cv.Invalid("max_value must be greater than min_value") return config @@ -69,7 +70,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Get the BL0940 component instance bl0940 = await cg.get_variable(config[CONF_BL0940_ID]) 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..7e6403c3bc 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType from . import bl0940_ns @@ -69,27 +70,29 @@ DEFAULT_BL0940_LEGACY_EREF = 3.6e6 / 297 # methods to calculate voltage and current reference values -def calculate_voltage_reference(vref, r_one, r_two): +def calculate_voltage_reference(vref: float, r_one: float, r_two: float) -> float: # formula: 79931 / Vref * (R1 * 1000) / (R1 + R2) return 79931 / vref * (r_one * 1000) / (r_one + r_two) -def calculate_current_reference(vref, r_shunt): +def calculate_current_reference(vref: float, r_shunt: float) -> float: # formula: 324004 * RL / Vref return 324004 * r_shunt / vref -def calculate_power_reference(voltage_reference, current_reference): +def calculate_power_reference( + voltage_reference: float, current_reference: float +) -> float: # calculate power reference based on voltage and current reference return voltage_reference * current_reference * 4046 / 324004 / 79931 -def calculate_energy_reference(power_reference): +def calculate_energy_reference(power_reference: float) -> float: # formula: power_reference * 3600000 / (1638.4 * 256) return power_reference * 3600000 / (1638.4 * 256) -def validate_legacy_mode(config): +def validate_legacy_mode(config: ConfigType) -> ConfigType: # Only allow schematic calibration options if legacy_mode is False if config.get(CONF_LEGACY_MODE, True): forbidden = [ @@ -106,7 +109,7 @@ def validate_legacy_mode(config): return config -def set_command_defaults(config): +def set_command_defaults(config: ConfigType) -> ConfigType: # Set defaults for read_command and write_command based on legacy_mode legacy = config.get(CONF_LEGACY_MODE, True) if legacy: @@ -118,7 +121,7 @@ def set_command_defaults(config): return config -def set_reference_values(config): +def set_reference_values(config: ConfigType) -> ConfigType: # Set default reference values based on legacy_mode if config.get(CONF_LEGACY_MODE, True): config.setdefault(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_LEGACY_UREF) @@ -211,8 +214,19 @@ 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): + +async def to_code(config: ConfigType) -> None: 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/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/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index f9fe7f5a5e..5531fe411b 100644 --- a/esphome/components/bl0942/sensor.py +++ b/esphome/components/bl0942/sensor.py @@ -24,6 +24,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType CONF_CURRENT_REFERENCE = "current_reference" CONF_ENERGY_REFERENCE = "energy_reference" @@ -95,7 +96,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 56ac2ea147..1ef7967fa8 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ID, CONF_MAC_ADDRESS, CONF_NAME, + CONF_NOTIFY, CONF_ON_CONNECT, CONF_ON_DISCONNECT, CONF_SERVICE_UUID, @@ -16,11 +17,46 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import ID +from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_client"] CODEOWNERS = ["@buxtronix", "@clydebarrow"] DEPENDENCIES = ["esp32_ble_tracker"] +CONF_DESCRIPTOR_UUID = "descriptor_uuid" +CONF_ON_NOTIFY = "on_notify" + + +def validate_descriptor_not_notify(config: ConfigType) -> ConfigType: + """Reject descriptor_uuid combined with notify or on_notify. + + BLE descriptors cannot send notifications; only characteristics can, and + ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify. + """ + if CONF_DESCRIPTOR_UUID in config and ( + config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config + ): + raise cv.Invalid( + f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or " + f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove " + f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or " + f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor" + ) + return config + + +def notify_from_on_notify(config: ConfigType) -> ConfigType: + """Enable notifications when an on_notify automation is configured. + + The triggers have no registration path of their own; without notify the + automation would validate but never fire. + """ + if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]: + config = config.copy() + config[CONF_NOTIFY] = True + return config + + ble_client_ns = cg.esphome_ns.namespace("ble_client") BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase) BLEClientNode = ble_client_ns.class_("BLEClientNode") 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.cpp b/esphome/components/ble_client/ble_client.cpp index d41fb17961..25001c8f74 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -51,7 +51,9 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es for (auto *node : this->nodes_) node->gattc_event_handler(event, esp_gattc_if, param); - if (!this->services_.empty() && this->all_nodes_established_()) { + // The release frees the GATT cache that BLEClientBase's CCCD lookup still needs. + // The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here. + if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) { this->release_services(); ESP_LOGD(TAG, "All clients established, services released"); } diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index ca523251ef..f20df31816 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -34,6 +34,11 @@ class BLEClientNode { // This should be transitioned to Established once the node no longer needs // the services/descriptors/characteristics of the parent client. This will // allow some memory to be freed. + // The parent frees the peer's GATT cache once every node reports Established. + // Never report Established while an operation that reads that cache is outstanding. + // - esp_ble_gattc_register_for_notify() completes asynchronously. + // - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT. + // - BLEClientBase::register_for_notify() holds the release until the registration completes. espbt::ClientState node_state; BLEClient *parent() { return this->parent_; } @@ -44,7 +49,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/__init__.py b/esphome/components/ble_client/sensor/__init__.py index 0975640ece..7764955d89 100644 --- a/esphome/components/ble_client/sensor/__init__.py +++ b/esphome/components/ble_client/sensor/__init__.py @@ -14,13 +14,16 @@ from esphome.const import ( UNIT_DECIBEL_MILLIWATT, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" TYPE_CHARACTERISTIC = "characteristic" TYPE_RSSI = "rssi" @@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All( }, lower=True, ), + validate_descriptor_not_notify, + notify_from_on_notify, ) 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/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 4bd871dc81..5dbb7e42ed 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga break; } this->handle = chr->handle; - if (this->descr_uuid_.get_uuid().len > 0) { + if (this->descr_uuid_.is_set()) { auto *descr = chr->get_descriptor(this->descr_uuid_); if (descr == nullptr) { this->status_set_warning(); @@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga this->handle = descr->handle; } if (this->notify_) { - auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), - this->parent()->get_remote_bda(), chr->handle); + auto status = this->parent()->register_for_notify(chr->handle); if (status) { ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); } 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/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index 0f53cccdad..820f60845d 100644 --- a/esphome/components/ble_client/text_sensor/__init__.py +++ b/esphome/components/ble_client/text_sensor/__init__.py @@ -9,13 +9,16 @@ from esphome.const import ( CONF_TRIGGER_ID, ) -from .. import ble_client_ns +from .. import ( + CONF_DESCRIPTOR_UUID, + CONF_ON_NOTIFY, + ble_client_ns, + notify_from_on_notify, + validate_descriptor_not_notify, +) DEPENDENCIES = ["ble_client"] -CONF_DESCRIPTOR_UUID = "descriptor_uuid" - -CONF_ON_NOTIFY = "on_notify" adv_data_t = cg.std_vector.template(cg.uint8) adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") @@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All( } ) .extend(cv.polling_component_schema("60s")) - .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(ble_client.BLE_CLIENT_SCHEMA), + validate_descriptor_not_notify, + notify_from_on_notify, ) 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_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index 7eaa6af076..ed2b0a63a0 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } this->handle = chr->handle; - if (this->descr_uuid_.get_uuid().len > 0) { + if (this->descr_uuid_.is_set()) { auto *descr = chr->get_descriptor(this->descr_uuid_); if (descr == nullptr) { this->status_set_warning(); @@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->handle = descr->handle; } if (this->notify_) { - auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), - this->parent()->get_remote_bda(), chr->handle); + auto status = this->parent()->register_for_notify(chr->handle); if (status) { ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); } diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py new file mode 100644 index 0000000000..43ec736727 --- /dev/null +++ b/esphome/components/ble_device_base/__init__.py @@ -0,0 +1,366 @@ +""" +ble_device_base — the platform-neutral BLE layer. + +Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / +ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract +(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h) +on every platform. + +BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the +configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared +subclass, so there is no Python platform table here and no dependency in +either direction (C++-side, the compile-time alias header ble_hub_impl.h and the +defines.h mirror are the deliberate exceptions). A sensor extends +BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a +declared key even on strict schemas) and calls register_ble_device() in +to_code; a tracker component declares BLEHub as its codegen-class parent and +MUST call register_hub_provider() at import time — without it _require_hub +rejects configs that bind through the generated id (an explicit ble_hub_id: +bypasses the registry). Adding a new BLE chip requires a new in-tree tracker +component plus its alias arm and define (see above); out-of-tree BLE hubs +are not supported. + +AES-CCM decryption for encrypted advertisements is provided portably in +ble_aes_ccm.h. +""" + +from collections.abc import Callable +import re + +import esphome.codegen as cg +from esphome.components.const import CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_INTERVAL, + KEY_TARGET_PLATFORM, +) +from esphome.core import CORE, ID, KEY_CORE, TimePeriod +from esphome.types import ConfigType + +CODEOWNERS = ["@Bl00d-B0b"] + +CONF_BLE_HUB_ID = "ble_hub_id" + +# Number of parsed-advertisement listeners registered in this build; read via +# cg.get_slot_count() by esp32_ble_tracker's feature coupling. +LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" + + +ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") + +# The neutral tracker contract. Every tracker's codegen class declares this as +# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python +# only: C++-side the name is a per-platform alias (ble_hub_impl.h). +BLEHub = ble_device_base_ns.class_("BLEHub") + +# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). +ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener") + + +# Config keys that provide a BLEHub, registered by each tracker component at +# import time (a tracker's module is imported iff it can end up in the build). +# Used only to phrase an actionable error when a BLE consumer is configured +# without any tracker — the binding itself resolves any BLEHub subclass and +# needs no platform table. Out-of-tree BLE hubs are not supported; the +# registry and the messages below deal in in-tree trackers only. +_HUB_PROVIDERS: set[str] = set() + +# The in-tree trackers per target platform, so the missing-tracker error names +# them even in a fresh process where no tracker module has been imported yet (a +# consumer imports only ble_device_base, so the registry is empty exactly in +# the most common failure: the tracker was simply forgotten). Filtered by the +# current platform so an esp32 config is not told to add a Beken tracker; an +# unknown/absent platform falls back to every in-tree name. +_IN_TREE_HUB_PROVIDERS: dict[str, str] = { + "esp32": "esp32_ble_tracker", + "bk72xx": "bk72xx_ble_tracker", + "rp2": "rp2_ble_tracker", + "ln882x": "ln882h_ble_tracker", +} + + +def register_hub_provider(component: str) -> None: + """Called at import time by every component whose config key declares a BLEHub.""" + _HUB_PROVIDERS.add(component) + + +def _require_hub(value: ID) -> ID: + # Without this check a missing tracker surfaces at ID resolution as + # "Couldn't find any component that can be used for 'ble_device_base::BLEHub'" + # — a C++ class name the user never types. Component final validation cannot + # phrase it better: the ID pass runs first and its error skips all later + # steps. All explicitly configured components are loaded before any schema + # validates, so a registered provider in loaded_integrations is exact here. + if value.id is not None: + # Explicit ble_hub_id: — the user is pointing at a specific hub (the + # multi-hub disambiguation case). Let the ID pass judge it; its error + # names the missing id, which is accurate. + return value + if not _HUB_PROVIDERS & CORE.loaded_integrations: + # Defensive lookup rather than CORE.target_platform: the property + # raises when no platform is registered, and this message must never + # be the thing that crashes. In a real run the platform is always set + # (LoadTargetPlatformValidationStep runs before any other domain), so + # the unfiltered all-platforms fallback is reachable only from tests. + platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) + if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS: + # Known platform with no in-tree hub (esp8266, host, rtl87xx, …): + # listing the other platforms' trackers would misdirect, and + # out-of-tree BLE hubs are not supported. + raise cv.Invalid( + f"No BLE tracker exists for {platform}; BLE components are " + "not supported on this platform" + ) + in_tree = ( + {tracker} + if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform)) + else set(_IN_TREE_HUB_PROVIDERS.values()) + ) + # in_tree only: _HUB_PROVIDERS is import-time state that outlives + # CORE.reset() in a long-lived process (dashboard), so a tracker from + # an earlier build of another platform must not leak into the message. + # The gate above is immune — loaded_integrations resets per run. + names = ", ".join(sorted(in_tree)) + raise cv.Invalid(f"No BLE tracker configured — add one of: {names}") + return value + + +# Schema fragment binding a consumer to the configured BLE tracker: extend a +# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a +# trailing validator after a PREVENT_EXTRA schema would reject the explicit +# form before ever running. An omitted id resolves to the single declared +# tracker on any platform; multiple trackers are disambiguated with an +# explicit ble_hub_id. +BLE_DEVICE_SCHEMA = cv.Schema( + {cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)} +) + + +def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]: + """Transitional alias for the pre-migration binding key: esp32_ble_id -> + ble_hub_id. Warns and auto-migrates until removal; every migrated platform + prepends this to its CONFIG_SCHEMA so existing configs keep validating.""" + return cv.rename_key( + "esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component + ) + + +def request_irk_support() -> None: + """Compile in resolve_irk()'s software-AES path. Called by sensors with an + irk: option so builds without IRK do not carry the resolution code.""" + cg.add_define("USE_BLE_DEVICE_IRK") + + +# Number of GATT client connection slots in this build; sizes the platform +# backend's connection storage. +GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT" + +_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) + + +def request_gatt_client() -> None: + """Compile in the neutral GATT client contract (ble_gatt_client.h) and + claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT; + distinct from the proxy's validated connection budget). Called by + bluetooth_connection.new_gatt_backend() once per backend instance.""" + cg.add_define("USE_BLE_GATT_CLIENT") + _request_gatt_connection_slot() + + +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) + + +async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj: + """Register `var` as a parsed-advertisement listener on the configured hub.""" + hub = await cg.get_variable(config[CONF_BLE_HUB_ID]) + cg.add(hub.register_listener(var)) + _request_listener_slot() + return var + + +# ---- shared validation / codegen helpers (platform-neutral) ---- + + +def to_ble_units(value: cv.TimePeriod) -> int: + """Convert a scan time to the controller's 0.625 ms units. + + Used by both validation and codegen so what is validated is exactly what is + programmed — the truncation here is what makes the duty-cycle check below + meaningful. + """ + return value.total_microseconds // 625 + + +def validate_scan_parameters(config: ConfigType) -> ConfigType: + """Reject impossible window/interval/duration combinations at config time. + + The controller cannot scan for longer than the interval, and a too-short + duration would end the scan period almost immediately. Catching it here + gives a clear error instead of a runtime controller failure and a retry + loop. + """ + duration = config[CONF_DURATION] + interval = config[CONF_INTERVAL] + window = config[CONF_WINDOW] + + # Labels are reused in every error below; the optional one names its key. + windows = [("Scan window", window)] + if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None: + windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window)) + + for name, value in windows: + if value > interval: + raise cv.Invalid( + f"{name} ({value}) needs to be smaller than scan interval ({interval})" + ) + + # BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the + # controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range + # values here instead of letting the unit conversion silently overflow. + for name, value in (("Scan interval", interval), *windows): + if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000: + raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms") + + # Validate what actually reaches the controller: both values are truncated to + # whole 0.625 ms units, so a window/interval pair that differs by less than one + # unit collapses to the same value — silently programming a 100 % duty cycle + # (radio permanently on) from a config that asked for less. + interval_units = to_ble_units(interval) + for name, value in windows: + if to_ble_units(value) == interval_units and value < interval: + raise cv.Invalid( + f"{name} ({value}) and interval ({interval}) both truncate to " + f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty " + f"cycle. Separate them by at least 0.625 ms." + ) + + if interval.total_microseconds * 3 > duration.total_microseconds: + raise cv.Invalid( + f"Scan duration ({duration}) must cover at least three scan intervals " + f"({interval}): the scanner listens on one of the three BLE advertising " + f"channels per interval, so a shorter duration can miss devices entirely." + ) + + return config + + +# The historical scan window default shared by the trackers that do not pin +# their own; also the fallback for esp32's conditional default. +DEFAULT_SCAN_WINDOW = "30ms" + +CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window" + + +def scan_parameters_schema( + interval_default: str, + *, + window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW, + connection_window: bool = False, +) -> cv.All: + """Build the scan_parameters value schema shared by all BLE trackers. + + interval_default and window_default are per chip (e.g. esp32 320/30 ms, + bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks; + LN882H's SDK recommends 100/50 ms). window_default may also be a zero-arg + callable evaluated per validation when the user omits the key (esp32 uses + this to record that the window was defaulted, so a later validation step + can adjust it once sibling keys are resolved). The `active` option + (default on) is unconditional: active scanning is part of the tracker + contract — every current proxy client assumes it, so a passive-only + tracker must not share this schema. connection_window opts in to the + `connection_scan_window` option for trackers that can fall back to a + smaller window while a GATT connection is active. + """ + schema = { + cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds, + cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period, + cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period, + cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + } + if connection_window: + schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period + return cv.All(cv.Schema(schema), validate_scan_parameters) + + +BT_UUID16_FORMAT = "XXXX" +BT_UUID32_FORMAT = "XXXXXXXX" +BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + +_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$") +_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$") +_BT_UUID128_RE = re.compile( + "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" +) + + +# Validator table keyed by input length: (compiled pattern, label used in errors). +_BT_UUID_FORMATS = { + len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"), + len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"), + len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"), +} + + +def bt_uuid(value: str) -> str: + in_value = cv.string_strict(value) + value = in_value.upper() + + fmt = _BT_UUID_FORMATS.get(len(value)) + if fmt is None: + raise cv.Invalid( + f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format" + ) + pattern, label = fmt + if not pattern.match(value): + raise cv.Invalid( + f"Invalid hexadecimal value for {label} UUID format: '{in_value}'" + ) + return value + + +def as_hex(value: str) -> cg.RawExpression: + return cg.RawExpression(f"0x{value}ULL") + + +def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression: + value = value.replace("-", "") + cpp_array = [ + f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] + ] + if reverse: + cpp_array.reverse() + return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") + + +def as_hex_array(value: str) -> cg.RawExpression: + return _hex_array_expression(value, reverse=False) + + +def as_reversed_hex_array(value: str) -> cg.RawExpression: + return _hex_array_expression(value, reverse=True) + + +def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None: + """Emit the width-matched service-UUID setter for a consumer. + + 16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte + array (BLE wire order). Shared here so every sensor platform dispatches the + same way instead of carrying its own if/elif copy. + """ + if len(service_uuid) == len(BT_UUID16_FORMAT): + cg.add(var.set_service_uuid16(as_hex(service_uuid))) + elif len(service_uuid) == len(BT_UUID32_FORMAT): + cg.add(var.set_service_uuid32(as_hex(service_uuid))) + elif len(service_uuid) == len(BT_UUID128_FORMAT): + cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid))) + else: + # bt_uuid restricts lengths to exactly these three formats; if that + # ever loosens, fail the build instead of emitting no setter (a + # sensor whose match_by_ is unset silently never matches). ValueError, + # not cv.Invalid: this runs from to_code, after validation, where + # voluptuous errors surface as raw tracebacks. + raise ValueError(f"Unsupported UUID format: {service_uuid}") diff --git a/esphome/components/ble_device_base/automation.h b/esphome/components/ble_device_base/automation.h new file mode 100644 index 0000000000..ba3128c0ee --- /dev/null +++ b/esphome/components/ble_device_base/automation.h @@ -0,0 +1,118 @@ +// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses +// registered on a BLEHub, exposed by each tracker under its own automation +// names. parse_device()'s return feeds the "Found device" suppression. +// Constructors are templated on the hub type so this header also builds with +// no tracker present (host unit tests). + +#pragma once + +#include "ble_device.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::ble_device_base { + +// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. +class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } + + void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + + bool parse_device(const ESPBTDevice &device) override { + if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), + device.address_uint64()) == this->addresses_.end()) { + return false; + } + this->trigger(device); + return true; + } + + protected: + FixedVector addresses_; +}; + +// on_ble_service_data_advertise: fires when an advertisement contains service +// data for the given UUID. Optional single-MAC filter. +class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + template explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } + + void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &sd : device.get_service_datas()) { + if (sd.uuid == this->uuid_) { + this->trigger(sd.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_ble_manufacturer_data_advertise: fires when an advertisement contains +// manufacturer data for the given ID. Optional single-MAC filter. +class BLEManufacturerDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { + public: + template explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } + + void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast(uuid)); } + void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast(uuid)); } + void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); } + + void set_address(uint64_t address) { + this->address_ = address; + this->has_address_ = true; + } + + bool parse_device(const ESPBTDevice &device) override { + if (this->has_address_ && device.address_uint64() != this->address_) { + return false; + } + for (const auto &md : device.get_manufacturer_datas()) { + if (md.uuid == this->uuid_) { + this->trigger(md.data); + return true; + } + } + return false; + } + + protected: + ESPBTUUID uuid_{}; + uint64_t address_{0}; + bool has_address_{false}; +}; + +// on_scan_end: fires whenever a scan period ends (duration elapsed or stop +// requested). A listener whose on_scan_end() hook fires the trigger — never +// claims devices (parse_device always returns false). +class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener { + public: + template explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); } + + bool parse_device(const ESPBTDevice &device) override { return false; } + void on_scan_end() override { this->trigger(); } +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py new file mode 100644 index 0000000000..6acc4edb92 --- /dev/null +++ b/esphome/components/ble_device_base/automation.py @@ -0,0 +1,129 @@ +"""Shared codegen for the neutral BLE advertisement triggers (automation.h).""" + +from collections.abc import Callable +from typing import Any + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +from . import ( + BT_UUID16_FORMAT, + BT_UUID32_FORMAT, + BT_UUID128_FORMAT, + LISTENER_COUNT_DEFINE, + as_hex, + as_reversed_hex_array, + ble_device_base_ns, +) + +adv_data_t = cg.std_vector.template(cg.uint8) +adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") +ESPBTDeviceConstRef = ( + ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const") +) + +ESPBTAdvertiseTrigger = ble_device_base_ns.class_( + "ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef) +) +BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref) +) +BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_( + "BLEManufacturerDataAdvertiseTrigger", + automation.Trigger.template(adv_data_t_const_ref), +) +BLEEndOfScanTrigger = ble_device_base_ns.class_( + "BLEEndOfScanTrigger", automation.Trigger.template() +) + +# UUID string length -> setter width. 16/32-bit go out as plain hex literals, +# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an +# impossible length fails as a KeyError instead of silently picking a width +# (bt_uuid validation upstream only ever produces these three). +_UUID_WIDTHS = { + len(BT_UUID16_FORMAT): "16", + len(BT_UUID32_FORMAT): "32", + len(BT_UUID128_FORMAT): "128", +} + + +def uuid_trigger_schema( + trigger_class: MockObjClass, extra: dict[Any, Any] | None = None +) -> Callable[[Any], Any]: + """Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code(). + + `extra` carries the required UUID key (a cv marker, so a dict rather than + **kwargs); the optional single-mac filter is what uuid_trigger_to_code() + reads back. + """ + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + **(extra or {}), + } + ) + + +def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]: + """on_ble_advertise schema: multi-mac list filter, unlike the single-mac + uuid_trigger_schema() — pairs with advertise_trigger_to_code().""" + return automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + } + ) + + +def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]: + """on_scan_end schema: id only — pairs with scan_end_trigger_to_code().""" + return automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)} + ) + + +# Triggers register as ble_device_base listeners in their constructors; count +# them where they are created so no backend can undercount the StaticVector +# (push_back past capacity drops silently). Shares the define with +# register_ble_device() via the core slot-counter factory. +_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE) + + +async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_ble_advertise trigger (optional multi-mac filter).""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_addresses([it.as_hex for it in macs])) + await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) + _count_listener() + + +async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: + """Build an on_scan_end trigger.""" + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + _count_listener() + + +async def uuid_trigger_to_code( + conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str +) -> None: + """Build a UUID-filtered advertise trigger. + + The UUID width picks the setter: 16-/32-bit go out as a plain hex literal, + 128-bit as a reversed byte array (BLE wire order). + """ + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + uuid = conf[key] + width = _UUID_WIDTHS[len(uuid)] + value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid) + cg.add(getattr(trigger, f"{setter_prefix}{width}")(value)) + if (mac := conf.get(CONF_MAC_ADDRESS)) is not None: + cg.add(trigger.set_address(mac.as_hex)) + await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) + _count_listener() diff --git a/esphome/components/ble_device_base/ble_aes_ccm.cpp b/esphome/components/ble_device_base/ble_aes_ccm.cpp new file mode 100644 index 0000000000..3ff34acc34 --- /dev/null +++ b/esphome/components/ble_device_base/ble_aes_ccm.cpp @@ -0,0 +1,202 @@ +#include "ble_aes_ccm.h" + +#include +#include + +namespace esphome::ble_device_base { + +namespace { + +// AES-128 forward cipher only — CCM uses the block cipher in the encrypt +// direction for both the CTR keystream and the CBC-MAC. +const uint8_t SBOX[256] = { + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, // + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, // + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, // + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, // + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, // + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, // + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, // + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, // + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, // + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, // + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, // + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, // + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, // + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, // + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, // + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, // +}; + +const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36}; + +inline uint8_t xtime(uint8_t x) { return static_cast((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); } + +// AES-128 forward cipher with on-the-fly key schedule. +class Aes128 { + public: + explicit Aes128(const uint8_t key[16]) { + memcpy(this->rk_, key, 16); + for (size_t i = 16; i < 176; i += 4) { + uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]}; + if (i % 16 == 0) { + const uint8_t tmp = t[0]; + t[0] = static_cast(SBOX[t[1]] ^ RCON[i / 16]); + t[1] = SBOX[t[2]]; + t[2] = SBOX[t[3]]; + t[3] = SBOX[tmp]; + } + for (size_t j = 0; j < 4; j++) + this->rk_[i + j] = static_cast(this->rk_[i - 16 + j] ^ t[j]); + } + } + + void encrypt(const uint8_t in[16], uint8_t out[16]) const { + uint8_t s[16]; + memcpy(s, in, 16); + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[i]; + + for (size_t round = 1; round < 10; round++) { + for (uint8_t &b : s) + b = SBOX[b]; + shift_rows(s); + for (size_t c = 0; c < 4; c++) { + uint8_t *col = s + c * 4; + const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3]; + const uint8_t h = static_cast(a0 ^ a1 ^ a2 ^ a3); + col[0] ^= static_cast(h ^ xtime(static_cast(a0 ^ a1))); + col[1] ^= static_cast(h ^ xtime(static_cast(a1 ^ a2))); + col[2] ^= static_cast(h ^ xtime(static_cast(a2 ^ a3))); + col[3] ^= static_cast(h ^ xtime(static_cast(a3 ^ a0))); + } + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[round * 16 + i]; + } + + for (uint8_t &b : s) + b = SBOX[b]; + shift_rows(s); + for (size_t i = 0; i < 16; i++) + s[i] ^= this->rk_[160 + i]; + memcpy(out, s, 16); + } + + protected: + static void shift_rows(uint8_t s[16]) { + uint8_t t = s[1]; + s[1] = s[5]; + s[5] = s[9]; + s[9] = s[13]; + s[13] = t; + t = s[2]; + s[2] = s[10]; + s[10] = t; + t = s[6]; + s[6] = s[14]; + s[14] = t; + t = s[3]; + s[3] = s[15]; + s[15] = s[11]; + s[11] = s[7]; + s[7] = t; + } + + uint8_t rk_[176]; +}; + +} // namespace + +void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) { + Aes128 aes(key); + aes.encrypt(in, out); +} + +bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, + size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, + const uint8_t *tag, size_t tag_len) { + // CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte + // nonce L = 2; BTHome uses M = 4. + if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16) + return false; + const size_t l = 15 - nonce_len; + const size_t m = tag_len; + + const Aes128 aes(key); + + // Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian). + uint8_t a[16]; + auto build_ctr = [&](uint32_t counter) { + a[0] = static_cast(l - 1); + memcpy(a + 1, nonce, nonce_len); + memset(a + 1 + nonce_len, 0, l); + for (size_t i = 0; i < l; i++) + a[15 - i] = static_cast((counter >> (8 * i)) & 0xff); + }; + + // S_0 = E(A_0); its first m bytes mask the transmitted tag. + uint8_t s0[16]; + build_ctr(0); + aes.encrypt(a, s0); + + // CTR-decrypt ciphertext into plaintext using S_1, S_2, ... + uint8_t ks[16]; + for (size_t off = 0; off < ct_len; off += 16) { + build_ctr(static_cast(off / 16) + 1); + aes.encrypt(a, ks); + const size_t n = std::min(static_cast(16), ct_len - off); + for (size_t i = 0; i < n; i++) + plaintext[off + i] = static_cast(ciphertext[off + i] ^ ks[i]); + } + + // CBC-MAC over B_0 | (formatted AAD) | plaintext. + uint8_t x[16]; + uint8_t b0[16]; + const uint8_t flags = static_cast((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1)); + b0[0] = flags; + memcpy(b0 + 1, nonce, nonce_len); + memset(b0 + 1 + nonce_len, 0, l); + for (size_t i = 0; i < l; i++) + b0[15 - i] = static_cast((ct_len >> (8 * i)) & 0xff); + aes.encrypt(b0, x); // X_1 = E(B_0) + + if (aad_len > 0) { + // Only the < 2^16-2^8 encoding is needed for BLE-sized AAD. + uint8_t blk[16] = {0}; + blk[0] = static_cast((aad_len >> 8) & 0xff); + blk[1] = static_cast(aad_len & 0xff); + size_t ai = 0; + size_t pos = 2; + while (pos < 16 && ai < aad_len) + blk[pos++] = aad[ai++]; + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + while (ai < aad_len) { + memset(blk, 0, 16); + const size_t n = std::min(static_cast(16), aad_len - ai); + memcpy(blk, aad + ai, n); + ai += n; + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + } + } + + for (size_t off = 0; off < ct_len; off += 16) { + uint8_t blk[16] = {0}; + const size_t n = std::min(static_cast(16), ct_len - off); + memcpy(blk, plaintext + off, n); + for (size_t i = 0; i < 16; i++) + x[i] ^= blk[i]; + aes.encrypt(x, x); + } + + // Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag. + uint8_t diff = 0; + for (size_t i = 0; i < m; i++) + diff |= static_cast((x[i] ^ s0[i]) ^ tag[i]); + return diff == 0; +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_aes_ccm.h b/esphome/components/ble_device_base/ble_aes_ccm.h new file mode 100644 index 0000000000..d337ad8ecd --- /dev/null +++ b/esphome/components/ble_device_base/ble_aes_ccm.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +namespace esphome::ble_device_base { + +// Self-contained AES-128-CCM authenticated decryption (RFC 3610). +// +// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use +// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE +// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls +// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not +// put it on the application include path), so a sensor cannot rely on +// being available. This software implementation makes +// encrypted-advertisement decryption work on every BLE platform without a +// per-chip crypto dependency. Decryption volume is tiny (one short block per +// matching advertisement), so software AES is not a meaningful cost. +// +// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted +// bytes to `plaintext` and returns true. Returns false when authentication fails +// (the caller must then discard `plaintext`). The CCM parameters follow the +// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be +// null when `aad_len` is 0. +/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by +/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching +/// works identically on every platform with no chip crypto dependency. +void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]); + +bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad, + size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext, + const uint8_t *tag, size_t tag_len); + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_client_state.cpp b/esphome/components/ble_device_base/ble_client_state.cpp new file mode 100644 index 0000000000..55817024b5 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.cpp @@ -0,0 +1,26 @@ +#include "ble_client_state.h" + +namespace esphome::ble_device_base { + +const char *client_state_to_string(ClientState state) { + switch (state) { + case ClientState::INIT: + return "INIT"; + case ClientState::DISCONNECTING: + return "DISCONNECTING"; + case ClientState::IDLE: + return "IDLE"; + case ClientState::DISCOVERED: + return "DISCOVERED"; + case ClientState::CONNECTING: + return "CONNECTING"; + case ClientState::CONNECTED: + return "CONNECTED"; + case ClientState::ESTABLISHED: + return "ESTABLISHED"; + default: + return "UNKNOWN"; + } +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h new file mode 100644 index 0000000000..92754b70b4 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -0,0 +1,81 @@ +// ble_client_state.h +// +// Platform-neutral GATT client connection state types, shared by every +// platform's GATT client implementation (esp32_ble_client, bluetooth_connection +// backends). Moved here from esp32_ble_tracker, which re-exports them under its +// own namespace for backward compatibility. + +#pragma once + +#include + +namespace esphome::ble_device_base { + +/// ESPHome-private errors for the API's plain-int error fields, outside the +/// ATT code range so they cannot be mistaken for spec errors. -1 is +/// understood by API clients as "not connected". Shared by every GATT +/// client backend. +static constexpr int GATT_ERR_NOT_CONNECTED = -1; +static constexpr int GATT_ERR_NO_MEMORY = -2; +/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency, +/// e.g. a service table failing its own bounds checks. +static constexpr int GATT_ERR_UNLIKELY = 0x0E; + +/// Safety net shared by every GATT backend: force IDLE when the stack never +/// delivers its disconnect completion. +static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000; + +/// ATT MTU before negotiation completes (Bluetooth spec default). +static constexpr uint16_t DEFAULT_ATT_MTU = 23; + +// Preferred connection parameters shared by every platform's GATT client so +// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency +// 0). FAST covers connection setup and service discovery; MEDIUM is the +// steady state once established. Stack defaults (12.5-15 ms) are too slow for +// stable connections through WiFi-based BLE proxies, causing disconnections; +// MEDIUM balances responsiveness with bandwidth usage. +static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms +static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms +// The timeout value was increased from 6s to 8s to address stability issues observed +// in certain BLE devices when operating through WiFi-based BLE proxies. The longer +// timeout reduces the likelihood of disconnections during periods of high latency. +static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s + +// Fastest connection parameters for devices with short discovery timeouts +static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) +static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms +static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s + +enum class ClientState : uint8_t { + // Connection is allocated + INIT, + // Client is disconnecting + DISCONNECTING, + // Connection is idle, no device detected. + IDLE, + // Device advertisement found. + DISCOVERED, + // Connection in progress. + CONNECTING, + // Initial connection established. + CONNECTED, + // The client and sub-clients have completed setup. + ESTABLISHED, +}; + +// Helper function to convert ClientState to string +const char *client_state_to_string(ClientState state); + +enum class ConnectionType : uint8_t { + // The default connection type, we hold all the services in ram + // for the duration of the connection. + V1, + // The client has a cache of the services and mtu so we should not + // fetch them again + V3_WITH_CACHE, + // The client does not need the services and mtu once we send them + // so we should wipe them from memory as soon as we send them + V3_WITHOUT_CACHE +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp new file mode 100644 index 0000000000..23ca6b1dbd --- /dev/null +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -0,0 +1,598 @@ +// ble_device.cpp +// +// Platform-neutral implementation of the shared BLE advertisement types. +// Parses raw BLE advertisement data into ESPBTDevice. + +#include "ble_device.h" + +#include "ble_aes_ccm.h" + +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ble_device_base { + +static const char *const TAG = "ble_device_base"; + +// Longest advertisement payload worth hex-dumping at VERY_VERBOSE +// (legacy advertising: 31-byte adv + 31-byte scan response). +static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62; + +// --------------------------------------------------------------------------- +// ESPBTUUID +// --------------------------------------------------------------------------- + +ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) { + ESPBTUUID ret; + ret.type_ = Type::UUID16; + ret.uuid_.uuid16 = uuid; + return ret; +} + +ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) { + ESPBTUUID ret; + ret.type_ = Type::UUID32; + ret.uuid_.uuid32 = uuid; + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) { + ESPBTUUID ret; + ret.type_ = Type::UUID128; + memcpy(ret.uuid_.uuid128, data, 16); + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) { + ESPBTUUID ret; + ret.type_ = Type::UUID128; + for (int i = 0; i < 16; i++) + ret.uuid_.uuid128[i] = data[15 - i]; + return ret; +} + +ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { + // Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw. + ESPBTUUID ret; + if (length == 4) { + // 16-bit UUID as 4-character hex string + auto parsed = parse_hex(data, length); + if (parsed.has_value()) { + ret.type_ = Type::UUID16; + ret.uuid_.uuid16 = parsed.value(); + } + } else if (length == 8) { + // 32-bit UUID as 8-character hex string + auto parsed = parse_hex(data, length); + if (parsed.has_value()) { + ret.type_ = Type::UUID32; + ret.uuid_.uuid32 = parsed.value(); + } + } else if (length == 16) { + // 16 raw bytes (little-endian 128-bit UUID) + ret.type_ = Type::UUID128; + memcpy(ret.uuid_.uuid128, reinterpret_cast(data), 16); + } else if (length == 36) { + // Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + ret.type_ = Type::UUID128; + int n = 0; + for (size_t i = 0; i < length; i += 2) { + if (data[i] == '-') + i++; + uint8_t msb = data[i]; + uint8_t lsb = data[i + 1]; + if (msb > '9') + msb -= 7; + if (lsb > '9') + lsb -= 7; + ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F); + } + } else { + ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data); + } + return ret; +} + +#ifdef USE_ESP32 +ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { + if (uuid.len == 0) // the unset sentinel get_uuid() emits + return {}; + if (uuid.len == ESP_UUID_LEN_16) + return ESPBTUUID::from_uint16(uuid.uuid.uuid16); + if (uuid.len == ESP_UUID_LEN_32) + return ESPBTUUID::from_uint32(uuid.uuid.uuid32); + return ESPBTUUID::from_raw(uuid.uuid.uuid128); +} + +esp_bt_uuid_t ESPBTUUID::get_uuid() const { + esp_bt_uuid_t ret; + switch (this->type_) { + case Type::UNSET: + ret.len = 0; + memset(&ret.uuid, 0, sizeof(ret.uuid)); + break; + case Type::UUID16: + ret.len = ESP_UUID_LEN_16; + ret.uuid.uuid16 = this->uuid_.uuid16; + break; + case Type::UUID32: + ret.len = ESP_UUID_LEN_32; + ret.uuid.uuid32 = this->uuid_.uuid32; + break; + default: + case Type::UUID128: + ret.len = ESP_UUID_LEN_128; + memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128); + break; + } + return ret; +} + +void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { + this->scan_result_ = &scan_result; + // BLEScanResult's bda is most-significant octet first; the neutral ingest + // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ + // address_str_to() then produce exactly the historical esp32 values. + uint8_t mac_lsb_first[MAC_ADDRESS_SIZE]; + for (uint8_t i = 0; i < 6; i++) + mac_lsb_first[i] = scan_result.bda[5 - i]; + this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, + scan_result.adv_data_len + scan_result.scan_rsp_len); +} +#endif // USE_ESP32 + +ESPBTUUID ESPBTUUID::as_128bit() const { + // Widening an unset UUID stays unset; expanding it would produce a set 0x0000 base UUID. + if (this->type_ == Type::UNSET || this->type_ == Type::UUID128) + return *this; + uint8_t data[16]; + this->to_128bit_(data); + return ESPBTUUID::from_raw(data); +} + +bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { + // Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains. + switch (this->type_) { + case Type::UNSET: + return false; + case Type::UUID16: + return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1; + case Type::UUID32: + for (uint8_t i = 0; i < 3; i++) { + bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1; + bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2; + if (a && b) + return true; + } + return false; + case Type::UUID128: + for (uint8_t i = 0; i < 15; i++) { + if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2) + return true; + } + return false; + } + return false; +} + +const char *ESPBTUUID::to_str(char *buf) const { + // Identical output format to esp32_ble::ESPBTUUID::to_str. + char *pos = buf; + switch (this->type_) { + case Type::UNSET: + memcpy(buf, "None", 5); + return buf; + case Type::UUID16: + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F); + *pos = 0; // NUL-terminate + return buf; + case Type::UUID32: + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) + *pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F); + *pos = 0; // NUL-terminate + return buf; + default: + case Type::UUID128: + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + for (int8_t i = 15; i >= 0; i--) { + uint8_t byte = this->uuid_.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) + *pos++ = '-'; + } + *pos = 0; // NUL-terminate + return buf; + } +} + +void ESPBTUUID::to_128bit_(uint8_t out[16]) const { + // Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit + // value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit(). + // Callers screen out UNSET first (operator==, as_128bit); it would expand like 0x0000. + static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + if (this->type_ == Type::UUID128) { + memcpy(out, this->uuid_.uuid128, 16); + return; + } + memcpy(out, BASE, 16); + const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16; + const size_t len = (this->type_ == Type::UUID32) ? 4 : 2; + for (size_t i = 0; i < len; i++) + out[12 + i] = (value >> (i * 8)) & 0xFF; +} + +bool ESPBTUUID::operator==(const ESPBTUUID &other) const { + if (this->type_ == other.type_) { + switch (this->type_) { + case Type::UNSET: + return true; + case Type::UUID16: + return this->uuid_.uuid16 == other.uuid_.uuid16; + case Type::UUID32: + return this->uuid_.uuid32 == other.uuid_.uuid32; + case Type::UUID128: + return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0; + } + return false; + } + // Unset never equals a set UUID; 0x0000 is a valid value, distinct from "not configured". + if (this->type_ == Type::UNSET || other.type_ == Type::UNSET) + return false; + // Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a + // configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity). + uint8_t a[16]; + uint8_t b[16]; + this->to_128bit_(a); + other.to_128bit_(b); + return memcmp(a, b, 16) == 0; +} + +// --------------------------------------------------------------------------- +// ESPBLEiBeacon +// --------------------------------------------------------------------------- + +ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); } + +optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) { + // iBeacon manufacturer specific data (after company-ID bytes have been stripped): + // [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes + if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C + return {}; + if (data.data.size() != 23) + return {}; + // Require the iBeacon sub-type/length prefix — stricter than the legacy + // esp32 parser, which accepted any 23-byte Apple payload and surfaced + // non-iBeacon frames as garbage beacons. + if (data.data[0] != 0x02 || data.data[1] != 0x15) { + if (prefix_rejected != nullptr) + *prefix_rejected = true; + return {}; + } + return ESPBLEiBeacon(data.data.data()); +} + +// --------------------------------------------------------------------------- +// ESPBTDevice +// --------------------------------------------------------------------------- + +optional ESPBTDevice::get_ibeacon() const { + bool prefix_rejected = false; + uint8_t rejected_sub_type = 0; + uint8_t rejected_len = 0; + for (const auto &it : this->manufacturer_datas_) { + bool rejected = false; + auto res = ESPBLEiBeacon::from_manufacturer_data(it, &rejected); + if (res.has_value()) + return res; + if (rejected && !prefix_rejected) { + prefix_rejected = true; + rejected_sub_type = it.data[0]; + rejected_len = it.data[1]; + } + } + if (prefix_rejected) { + // Only when no beacon was found at all: these frames were accepted before + // the prefix check, so their disappearance must be observable at the + // default log level. Throttled so a chatty non-iBeacon Apple advertiser + // cannot flood the log; a different address may bypass the shared window + // so that advertiser cannot mask the device that actually regressed — but + // with a 1 s floor, or two alternating advertisers log every frame. + static uint32_t last_log = 0; + static uint64_t last_addr = 0; + const uint32_t now = millis(); + const uint64_t addr = this->address_uint64(); + const uint32_t since = now - last_log; + if (last_log == 0 || since > 60000 || (addr != last_addr && since > 1000)) { + last_log = now; + last_addr = addr; + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(TAG, "%s: 23-byte Apple frame without iBeacon prefix ignored (sub-type 0x%02X len 0x%02X)", + this->address_str_to(addr_buf), rejected_sub_type, rejected_len); + } + } + return {}; +} + +const char *ESPBTDevice::address_type_str() const { + switch (this->address_type_) { + case BLE_ADDR_TYPE_PUBLIC: + return "PUBLIC"; + case BLE_ADDR_TYPE_RANDOM: + return "RANDOM"; + case BLE_ADDR_TYPE_RPA_PUBLIC: + return "RPA_PUBLIC"; + case BLE_ADDR_TYPE_RPA_RANDOM: + return "RPA_RANDOM"; + default: + return "UNKNOWN"; + } +} + +void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, + uint16_t data_len) { + // Ingest is BLE controller order (LSB-first); store in printable (MSB-first) + // order so the raw address() accessor matches the historical esp32 layout. + for (uint8_t i = 0; i < 6; i++) + this->address_[i] = mac[5 - i]; + this->address_type_ = addr_type; + this->rssi_ = rssi; + this->name_len_ = 0; + this->name_[0] = '\0'; + this->service_uuids_.clear(); + this->manufacturer_datas_.clear(); + this->service_datas_.clear(); + this->tx_powers_.clear(); + this->appearance_.reset(); + this->ad_flag_.reset(); + this->parse_adv_(data, data_len); + +#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE + char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGVV(TAG, + "Parse Result:\n" + " Address: %s (%s)\n" + " RSSI: %d\n" + " Name: '%s'", + this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_); + for (auto &it : this->tx_powers_) { + ESP_LOGVV(TAG, " TX Power: %d", it); + } + if (this->appearance_.has_value()) { + ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_); + } + if (this->ad_flag_.has_value()) { + ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); + } + char uuid_buf[UUID_STR_LEN]; + for (auto &uuid : this->service_uuids_) { + ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf)); + } + char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)]; + for (auto &mfg_data : this->manufacturer_datas_) { + auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data); + if (ibeacon.has_value()) { + ESP_LOGVV(TAG, + " Manufacturer iBeacon:\n" + " UUID: %s\n" + " Major: %u\n" + " Minor: %u\n" + " TXPower: %d", + ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(), + ibeacon.value().get_signal_power()); + } else { + ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf), + format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size())); + } + } + for (auto &svc_data : this->service_datas_) { + ESP_LOGVV(TAG, + " Service data:\n" + " UUID: %s\n" + " Data: %s", + svc_data.uuid.to_str(uuid_buf), + format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size())); + } + ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len)); +#endif // ESPHOME_LOG_HAS_VERY_VERBOSE +} + +// Remove before 2027.2.0 +std::string ESPBTDevice::address_str() const { + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + return std::string(this->address_str_to(buf)); +} + +const char *ESPBTDevice::address_str_to(char *buf) const { + // address_ is stored in printable (MSB-first) order. + format_mac_addr_upper(this->address_, buf); + return buf; +} + +uint64_t ESPBTDevice::address_uint64() const { + // address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics). + uint64_t addr = 0; + for (int i = 0; i < 6; i++) + addr |= static_cast(this->address_[i]) << ((5 - i) * 8); + return addr; +} + +bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { +#ifdef USE_BLE_DEVICE_IRK + // Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits]. + // The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes). + // Uses the portable software AES-128 shared with the CCM decryptor, so IRK + // matching behaves identically on every platform (volume is one block per + // advertisement from a matching RPA device — software AES is not a cost). + uint8_t ecb_plaintext[16] = {0}; + uint8_t ecb_ciphertext[16]; + const uint64_t addr64 = this->address_uint64(); + ecb_plaintext[13] = (addr64 >> 40) & 0xff; + ecb_plaintext[14] = (addr64 >> 32) & 0xff; + ecb_plaintext[15] = (addr64 >> 24) & 0xff; + aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext); + return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && + ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); +#else + // No sensor configured an irk: in this build; the AES core is compiled out. + (void) irk; + return false; +#endif +} + +void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { + // BLE AD structure TLV: [length][type][value...] + // length includes the type byte. + uint16_t offset = 0; + while (offset < len) { + uint8_t ad_len = payload[offset++]; + if (ad_len == 0) + continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too) + if (offset + ad_len > len) + break; + uint8_t ad_type = payload[offset]; + const uint8_t *ad_data = &payload[offset + 1]; + uint8_t ad_data_len = ad_len - 1; + offset += ad_len; + + switch (ad_type) { + case 0x01: // Flags + if (ad_data_len >= 1) + this->ad_flag_ = ad_data[0]; + break; + + case 0x08: // Shortened Local Name + case 0x09: // Complete Local Name + // Keep the longest name seen — a merged adv + scan-response frame may carry both the + // shortened and the complete name, and the shortened form must never replace the + // complete one (same rule as esp32_ble_tracker's parse_adv_). + if (ad_data_len > this->name_len_) { + uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast(ad_data_len); + memcpy(this->name_, ad_data, name_len); + this->name_[name_len] = '\0'; + this->name_len_ = name_len; + } + break; + + case 0x0A: // TX Power Level + if (ad_data_len >= 1) + this->tx_powers_.push_back(static_cast(ad_data[0])); + break; + + case 0x19: // Appearance + if (ad_data_len >= 2) + this->appearance_ = static_cast(ad_data[0]) | (static_cast(ad_data[1]) << 8); + break; + + case 0x02: // Incomplete List of 16-bit Service UUIDs + case 0x03: // Complete List of 16-bit Service UUIDs + for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) { + uint16_t uuid = (static_cast(ad_data[i + 1]) << 8) | ad_data[i]; + this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid)); + } + break; + + case 0x04: // Incomplete List of 32-bit Service UUIDs + case 0x05: // Complete List of 32-bit Service UUIDs + for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) { + uint32_t uuid = (static_cast(ad_data[i + 3]) << 24) | + (static_cast(ad_data[i + 2]) << 16) | (static_cast(ad_data[i + 1]) << 8) | + ad_data[i]; + this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid)); + } + break; + + case 0x06: // Incomplete List of 128-bit Service UUIDs + case 0x07: // Complete List of 128-bit Service UUIDs + for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16) + this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i])); + break; + + case 0xFF: // Manufacturer Specific Data + if (ad_data_len >= 2) { + uint16_t company_id = (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint16(company_id); + sd.data.assign(ad_data + 2, ad_data + ad_data_len); + this->manufacturer_datas_.push_back(std::move(sd)); + } + break; + + case 0x16: // Service Data — 16-bit UUID + if (ad_data_len >= 2) { + uint16_t uuid = (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint16(uuid); + sd.data.assign(ad_data + 2, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + case 0x20: // Service Data — 32-bit UUID + if (ad_data_len >= 4) { + uint32_t uuid = (static_cast(ad_data[3]) << 24) | (static_cast(ad_data[2]) << 16) | + (static_cast(ad_data[1]) << 8) | ad_data[0]; + ServiceData sd; + sd.uuid = ESPBTUUID::from_uint32(uuid); + sd.data.assign(ad_data + 4, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + case 0x21: // Service Data — 128-bit UUID + if (ad_data_len >= 16) { + ServiceData sd; + sd.uuid = ESPBTUUID::from_raw(ad_data); + sd.data.assign(ad_data + 16, ad_data + ad_data_len); + this->service_datas_.push_back(std::move(sd)); + } + break; + + default: + break; + } + } +} + +// --------------------------------------------------------------------------- +// DiscoveredDeviceLog +// --------------------------------------------------------------------------- + +void DiscoveredDeviceLog::log_device(const char *tag, const ESPBTDevice &device) { +#ifdef ESPHOME_LOG_HAS_DEBUG + // Everything here feeds ESP_LOGD: below DEBUG the whole body (including the + // dedup vector growth) would be pure overhead, so compile it out entirely. + const uint64_t address = device.address_uint64(); + for (auto &disc : this->already_discovered_) { + if (disc == address) + return; + } + this->already_discovered_.push_back(address); + + char addr_buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + ESP_LOGD(tag, + "Found device %s RSSI=%d\n" + " Address Type: %s", + device.address_str_to(addr_buf), device.get_rssi(), device.address_type_str()); + if (!device.get_name().empty()) { + ESP_LOGD(tag, " Name: '%s'", device.get_name().c_str()); + } + for (auto &tx_power : device.get_tx_powers()) { + ESP_LOGD(tag, " TX Power: %d", tx_power); + } +#endif // ESPHOME_LOG_HAS_DEBUG +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h new file mode 100644 index 0000000000..668f7e09f8 --- /dev/null +++ b/esphome/components/ble_device_base/ble_device.h @@ -0,0 +1,292 @@ +// ble_device.h +// +// Platform-neutral BLE advertisement types — the generic base every BLE consumer +// (sensor components, bluetooth_proxy, automation triggers) builds against: +// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener +// +// These types are owned here on EVERY platform, with no chip-SDK types in their +// public surface. Platform trackers produce them: +// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and +// re-exports these names (esp32 only) for backward compatibility; +// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly. + +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" + +#include +#include +#include +#include +#include + +#if defined(__cpp_lib_span) +#include +#endif + +#ifdef USE_ESP32 +// Historical esp32_ble API surface (below, under the same define) uses the +// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32. +#include "esphome/components/esp32_ble/ble_scan_result.h" +#include +#endif + +namespace esphome::ble_device_base { + +using adv_data_t = std::vector; + +// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t). +static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0; +static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1; +static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2; +static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3; + +/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" +static constexpr size_t UUID_STR_LEN = 37; + +// --------------------------------------------------------------------------- +// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type. +// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t +// conversions live in esp32_ble (esp32-only adapters), not here. +// --------------------------------------------------------------------------- + +class ESPBTUUID { + public: + ESPBTUUID() = default; + + static ESPBTUUID from_uint16(uint16_t uuid); + static ESPBTUUID from_uint32(uint32_t uuid); + /// Construct from raw 16-byte little-endian UUID. + static ESPBTUUID from_raw(const uint8_t *data); + /// Construct from raw 16-byte big-endian UUID (reversed on store). + static ESPBTUUID from_raw_reversed(const uint8_t *data); + /// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes, + /// or the 36-char dashed UUID form. Same semantics as esp32_ble historically. + static ESPBTUUID from_raw(const char *data, size_t length); + static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); } + static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); } + static ESPBTUUID from_raw(std::initializer_list data) { + return from_raw(reinterpret_cast(data.begin()), data.size()); + } + +#ifdef USE_ESP32 + /// Source compatibility with the historical esp32_ble API (esp32 builds only). + static ESPBTUUID from_uuid(esp_bt_uuid_t uuid); + esp_bt_uuid_t get_uuid() const; +#endif + + /// Expand to the 128-bit Bluetooth Base UUID form. + ESPBTUUID as_128bit() const; + + /// True if the UUID value contains the adjacent byte pair (data1, data2). + bool contains(uint8_t data1, uint8_t data2) const; + + bool operator==(const ESPBTUUID &other) const; + bool operator!=(const ESPBTUUID &other) const { return !(*this == other); } + + /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form, or "None" for an + /// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf. + const char *to_str(char *buf) const; +#if defined(__cpp_lib_span) + const char *to_str(std::span output) const { return this->to_str(output.data()); } +#endif + // UNSET is the default-constructed state; get_uuid() reports it as len 0 (the historical sentinel). + enum class Type : uint8_t { UNSET, UUID16, UUID32, UUID128 }; + Type type() const { return this->type_; } + /// True if a UUID has been configured (not default-constructed). + bool is_set() const { return this->type_ != Type::UNSET; } + uint16_t uuid16() const { return this->uuid_.uuid16; } + uint32_t uuid32() const { return this->uuid_.uuid32; } + const uint8_t *uuid128() const { return this->uuid_.uuid128; } + + protected: + // Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian). + void to_128bit_(uint8_t out[16]) const; + + Type type_{Type::UNSET}; + union { + uint16_t uuid16; + uint32_t uuid32; + uint8_t uuid128[16]; + } uuid_{}; +}; + +// --------------------------------------------------------------------------- +// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types) +// --------------------------------------------------------------------------- + +struct ServiceData { + ESPBTUUID uuid; + adv_data_t data; +}; + +// --------------------------------------------------------------------------- +// ESPBLEiBeacon +// --------------------------------------------------------------------------- + +class ESPBLEiBeacon { + public: + ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } + explicit ESPBLEiBeacon(const uint8_t *data); + /// prefix_rejected: caller must initialise to false; set to true ONLY when a + /// 23-byte Apple frame was refused for lacking the 0x02/0x15 iBeacon prefix — + /// the case the legacy esp32 parser accepted. Never written on accept or on + /// the non-Apple/wrong-size rejects. The caller with the device address does + /// the logging (see ESPBTDevice::get_ibeacon()). + static optional from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr); + + uint16_t get_major() const { return byteswap(this->beacon_data_.major); } + uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); } + int8_t get_signal_power() const { return this->beacon_data_.signal_power; } + ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); } + + protected: + struct PACKED BeaconData { + uint8_t sub_type; + uint8_t length; + uint8_t proximity_uuid[16]; + uint16_t major; + uint16_t minor; + int8_t signal_power; + } beacon_data_; +}; + +/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks. +/// Trackers with LSB-native SDKs call this at the emit site before filling +/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent +/// for an already parsed device, whose address is stored MSB-first. +inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { + uint64_t addr = 0; + for (int i = 0; i < 6; i++) + addr |= static_cast(mac[i]) << (i * 8); + return addr; +} + +/// Unpack a uint64 BLE address into printable (MSB-first) byte order — +/// the order bd_addr_t / esp_bd_addr_t style APIs expect. +inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) { + for (int i = 0; i < 6; i++) + out[i] = (address >> ((5 - i) * 8)) & 0xFF; +} + +// --------------------------------------------------------------------------- +// ESPBTDevice — parsed BLE advertisement +// --------------------------------------------------------------------------- + +class ESPBTDevice { + public: + /// Populate from a raw scan result delivered by a BLE tracker backend. + /// mac is least-significant octet first (BLE controller convention). + void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len); + + // Alias the core constant so the two cannot drift apart. + static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE; + + /// Return MAC as "XX:XX:XX:XX:XX:XX" string. + ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0") + std::string address_str() const; + /// Writes "XX:XX:XX:XX:XX:XX\0" into buf (>= MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes), returns buf. + const char *address_str_to(char *buf) const; +#if defined(__cpp_lib_span) + const char *address_str_to(std::span buf) const { + return this->address_str_to(buf.data()); + } +#endif + /// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64). + uint64_t address_uint64() const; + /// Raw MAC bytes in printable (MSB-first) order — matches the historical + /// esp32 layout (ESP-IDF bda order). + const uint8_t *address() const { return address_; } +#ifdef USE_ESP32 + // Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t. + esp_ble_addr_type_t get_address_type() const { return static_cast(this->address_type_); } + /// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result. + /// Prefer ESPBTDevice::from_scan_result(); deprecation is a follow-up pending + /// consumer feedback on the raw scan-result fields. + void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result); + // Exposed through a function for use in lambdas + const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; } +#else + uint8_t get_address_type() const { return this->address_type_; } +#endif + /// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or + /// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above. + const char *address_type_str() const; + + int get_rssi() const { return rssi_; } + /// Advertised name as a view into the fixed buffer (always NUL-terminated, + /// so c_str() is safe); converts implicitly to std::string where needed. + StringRef get_name() const { return StringRef(this->name_, this->name_len_); } + + const std::vector &get_service_uuids() const { return service_uuids_; } + const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } + const std::vector &get_service_datas() const { return service_datas_; } + const std::vector &get_tx_powers() const { return tx_powers_; } + const optional &get_appearance() const { return appearance_; } + const optional &get_ad_flag() const { return ad_flag_; } + + /// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah" + /// function, AES-128). Uses the portable software AES shared with the CCM + /// decryptor; compiled only when a sensor configures irk: (request_irk_support). + bool resolve_irk(const uint8_t *irk) const; + + optional get_ibeacon() const; + + protected: + void parse_adv_(const uint8_t *payload, uint16_t len); + + // Max name bytes in a legacy advertisement AD element (31-byte PDU minus + // the 2-byte element header); every in-tree tracker scans legacy PDUs only. + static constexpr uint8_t MAX_ADV_NAME_LEN = 29; + + uint8_t address_[MAC_ADDRESS_SIZE]{0}; + uint8_t address_type_{0}; + int rssi_{0}; + // Fixed buffer instead of std::string: no per-advertisement heap churn on + // the scan path, and no libstdc++ string/exception machinery in the image. + char name_[MAX_ADV_NAME_LEN + 1]{}; + uint8_t name_len_{0}; + std::vector service_uuids_{}; + std::vector manufacturer_datas_{}; + std::vector service_datas_{}; +#ifdef USE_ESP32 + const esp32_ble::BLEScanResult *scan_result_{nullptr}; +#endif + std::vector tx_powers_{}; + optional appearance_{}; + optional ad_flag_{}; +}; + +// --------------------------------------------------------------------------- +// DiscoveredDeviceLog — shared per-scan-period "Found device" DEBUG logger +// --------------------------------------------------------------------------- + +/// Per-scan-period "Found device" DEBUG logger, deduplicated by MAC address. +/// Shared by all tracker backends so the output format and dedup behaviour stay +/// identical by construction (single implementation instead of per-chip copies). +class DiscoveredDeviceLog { + public: + /// Log the device at DEBUG the first time its MAC is seen this scan period. + void log_device(const char *tag, const ESPBTDevice &device); + /// Reset the per-period dedup list (call when a scan period ends). + void clear() { this->already_discovered_.clear(); } + + protected: + std::vector already_discovered_; +}; + +// --------------------------------------------------------------------------- +// ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers) +// --------------------------------------------------------------------------- + +class ESPBTDeviceListener { + public: + virtual ~ESPBTDeviceListener() = default; + /// Called at the end of each scan duration period. + virtual void on_scan_end() {} + virtual bool parse_device(const ESPBTDevice &device) = 0; +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h new file mode 100644 index 0000000000..b95fb6878a --- /dev/null +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -0,0 +1,148 @@ +// ble_gatt_client.h +// +// Platform-neutral GATT client connection contract. +// +// Exactly one GATT backend exists per build, so BLEGattConnection is a +// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract +// interface. +// A consumer - the hub wrapper streaming the raw database, or a direct +// consumer owning a dedicated backend and resolving handles by UUID - +// drives it and receives completions through the GattClientListener +// interface. All listener calls are delivered on the ESPHome main loop; +// borrowed data pointers are valid only for the duration of the call. +// +// Error domain (plain int, forwarded to the API without translation): +// 0 success +// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree) +// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw +// ESP_FAIL from the stack shares this value; both read as a +// failed, unusable connection on the client side) +// GATT_ERR_NO_MEMORY (-2) backend storage exhausted +// anything else: platform stack error/status code, surfaced opaquely. +// Connection events carry HCI status/disconnect reason codes (same code +// space on every controller). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "ble_client_state.h" +#include "ble_device.h" + +#include +#include + +namespace esphome::ble_device_base { + +// Materialized GATT database of a connected peer, discovered by the backend +// and streamed to the API by the consumer. Flat arrays with index ranges +// (not pointers): a service owns characteristics +// [first_characteristic, first_characteristic + characteristic_count) and a +// characteristic owns descriptors [first_descriptor, ...) — discovery is +// depth-first, so the ranges are naturally contiguous. +struct GattDescriptor { + ESPBTUUID uuid; + uint16_t handle; +}; + +struct GattCharacteristic { + ESPBTUUID uuid; + uint16_t value_handle; + // Needed to rebuild the stack's characteristic object for CCCD operations. + uint16_t end_handle; + uint8_t properties; // Bluetooth spec property bitfield + uint16_t first_descriptor; + uint16_t descriptor_count; +}; + +struct GattService { + ESPBTUUID uuid; + uint16_t start_handle; + uint16_t end_handle; + uint16_t first_characteristic; + uint16_t characteristic_count; +}; + +/// Borrowed view of the backend-owned service table. Valid from a successful +/// on_service_discovery_done() until release_services(). Characteristics and +/// descriptors are reached through the per-service/per-characteristic index +/// ranges; the array totals let a consumer bounds-check those ranges instead +/// of trusting the backend's discovery bookkeeping blindly. +struct GattServiceTable { + const GattService *services{nullptr}; + const GattCharacteristic *characteristics{nullptr}; + const GattDescriptor *descriptors{nullptr}; + uint16_t service_count{0}; + uint16_t characteristic_count{0}; + uint16_t descriptor_count{0}; +}; + +/// The event surface a backend delivers completions through - the one place +/// with genuine runtime polymorphism (several consumer types, one non-virtual +/// backend). Methods default to no-ops; consumers override what they consume. +/// No destructor: components are never destroyed. +/// on_connection_state carries the negotiated MTU and an HCI status/reason. +/// Codegen wires the listener before setup(), so backends skip null checks. +class GattClientListener { + public: + virtual void on_connection_state(bool connected, uint16_t mtu, int error) {} + virtual void on_service_discovery_done(int error) {} + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + virtual void on_write_result(uint16_t handle, int error) {} + virtual void on_notify_state(uint16_t handle, bool enabled, int error) {} + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + virtual void on_pairing_result(int status) {} +}; + +// The BLEGattConnection op surface, asserted where the alias binds +// (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives +// through the listener) or a synchronous error (busy, not connected, stack +// rejection); one operation may be outstanding at a time. Semantics beyond +// the signatures: +// - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). +// - gatt_disconnect: also cancels a connect in progress (named to coexist +// with a platform stack's own void disconnect() on one backend class). +// Nonzero means nothing to tear down and no completion will follow; an +// accepted teardown (0) always reaches a terminal on_connection_state. +// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not +// started closing - the in-flight connect resumes and completes normally. +// False once the teardown owns the link (or nothing was scheduled). +// - notify_characteristic: local registration only; the CCCD write is the +// API client's responsibility (a plain write_descriptor). +// - get_service_table/release_services: backend-owned transient storage, +// released after streaming (release is idempotent). A backend may +// additionally provide its own service streamer (stream_service_batch on +// the concrete type, detected by the consumer at compile time) for +// arbitrary-size databases; the table then materializes only for consumers +// that ask for it. +// - completions: connect and gatt_disconnect land in on_connection_state, +// discover_services in on_service_discovery_done, pair in +// on_pairing_result, reads in on_read_result, notify_characteristic in +// on_notify_state, characteristic writes (with and without response) and +// descriptor writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) { + conn.set_listener(listener); + { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; + { conn.gatt_disconnect() } -> std::same_as; + { conn.cancel_gatt_disconnect() } -> std::same_as; + { conn.discover_services() } -> std::same_as; + { conn.read_characteristic(uint16_t{}) } -> std::same_as; + { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; + { conn.read_descriptor(uint16_t{}) } -> std::same_as; + { conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as; + { conn.notify_characteristic(uint16_t{}, true) } -> std::same_as; + { conn.pair() } -> std::same_as; + { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; + { conn.get_service_table() } -> std::same_as; + { conn.release_services() } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h new file mode 100644 index 0000000000..9da6371012 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub.h @@ -0,0 +1,122 @@ +// ble_hub.h +// +// The platform-neutral BLE tracker contract: shared types plus the method +// surface every tracker provides (documented below). Exactly one tracker +// exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not +// an abstract interface — no vtable, every hub call inlinable. Consumers +// include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub). +// +// Chip differences are expressed as data (HubCapabilities), never as +// platform conditionals in consumers. + +#pragma once + +#include "ble_device.h" +#include "esphome/core/defines.h" + +#include +#include + +namespace esphome::ble_device_base { + +/// One raw advertisement as delivered by the controller — a borrowed view, +/// valid only for the duration of the invoke() callback. +struct RawAdvertisement { + /// Producers convert their native byte order at the emit site, so no + /// byte-order convention crosses this contract. + uint64_t address; + const uint8_t *data; + uint16_t data_len; + int8_t rssi; // signed dBm + uint8_t addr_type; +}; + +/// Subscriber slot for the raw-advertisement stream (the bluetooth_proxy +/// path). The hub delivers on the ESPHome main loop. Same shape as +/// logger.h's LogCallback: an instance pointer plus a plain function +/// pointer — no virtuals, no std::function. +/// +/// Usage: +/// hub->set_raw_advertisement_callback({this, [](void *self, const RawAdvertisement &adv) { +/// static_cast(self)->on_raw_advertisement(adv); +/// }}); +struct RawAdvertisementCallback { + void *instance{nullptr}; + void (*fn)(void *instance, const RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no subscriber"; hubs must guard on this. + bool is_set() const { return this->fn != nullptr; } + void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } +}; + +/// Scanner lifecycle, wire-value aligned with the api enum so consumers cast +/// directly (pinned by static_asserts at the cast sites). +enum class ScannerState : uint8_t { + IDLE = 0, + STARTING = 1, + RUNNING = 2, + FAILED = 3, + STOPPING = 4, + STOPPED = 5, +}; + +/// Subscriber slot for scanner-state transitions; same shape as +/// RawAdvertisementCallback, delivered on the ESPHome main loop. Only hubs +/// that push provide the setter; consumers of the rest poll scan_running(). +struct ScannerStateCallback { + void *instance{nullptr}; + void (*fn)(void *instance, ScannerState state){nullptr}; + bool is_set() const { return this->fn != nullptr; } + void invoke(ScannerState state) const { this->fn(this->instance, state); } +}; + +/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs. +struct HubCapabilities { + /// Controller can send scan requests (active scanning). + bool active_scan; + /// Controller (or tracker) delivers advertisement + scan response as one merged + /// frame. When false, consumers relying on scan-response fields (e.g. names) + /// may only see them where the receiver merges per address (Home Assistant does). + bool merges_scan_response; + /// GATT client connections are available: the platform has a + /// bluetooth_connection backend (rp2 binds the BLEGattConnection alias in + /// bluetooth_connection_gatt_backend.h; esp32 uses its Bluedroid client). + /// Today: esp32 and rp2. + bool gatt; + /// request_scan_mode() is honored at runtime. Distinct from active_scan: + /// a passive-only controller can never switch, and a hub may support + /// active scanning yet still refuse the runtime switch (esp32_ble_tracker + /// drives its mode through its own tracker API). + bool scan_mode_switch; +}; + +// The BLEHub method surface, asserted where ble_hub_impl.h binds the alias. +// Semantics beyond the signatures: +// - register_listener: parsed-advertisement consumers (sensors, triggers). +// - set_raw_advertisement_callback: raw stream, one consumer at a time. +// - get_adapter_mac: printable order, out[0] = MSB. +// - scan_active: the current/configured mode sends scan requests. +// - request_scan_mode: false = cannot honor, state untouched (the caller +// reports the real state back); true = applied immediately, restarting a +// running scan. Honoring is advertised by HubCapabilities::scan_mode_switch. +// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback) +// and get_scanner_state() under USE_BLE_SCANNER_STATE_CALLBACK; the concept +// requires both exactly when that define is set. A push hub must emit a +// transition for every accepted or refused mode request - consumers skip +// their own mode report on push builds. +template +concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback, + uint8_t *mac) { + hub.register_listener(listener); + hub.set_raw_advertisement_callback(raw_callback); + { T::get_capabilities() } -> std::same_as; + hub.get_adapter_mac(mac); + { hub.scan_running() } -> std::same_as; + { hub.scan_active() } -> std::same_as; + { hub.request_scan_mode(true) } -> std::same_as; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + hub.set_scanner_state_callback(ScannerStateCallback{}); + { hub.get_scanner_state() } -> std::same_as; +#endif +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_hub_impl.h b/esphome/components/ble_device_base/ble_hub_impl.h new file mode 100644 index 0000000000..87214ca7f7 --- /dev/null +++ b/esphome/components/ble_device_base/ble_hub_impl.h @@ -0,0 +1,35 @@ +// ble_hub_impl.h +// +// Binds ble_device_base::BLEHub to the build's one tracker; each tracker's +// codegen emits its USE_*_BLE_TRACKER define. Consumers include this header, +// trackers include ble_hub.h (the contract). + +#pragma once + +#include "ble_hub.h" +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE_TRACKER) +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE esp32_ble_tracker::ESP32BLETracker +#elif defined(USE_RP2_BLE_TRACKER) +#include "esphome/components/rp2_ble_tracker/rp2_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE rp2_ble_tracker::RP2BLETracker +#elif defined(USE_BK72XX_BLE_TRACKER) +#include "esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE bk72xx_ble_tracker::BK72xxBLETracker +#elif defined(USE_LN882H_BLE_TRACKER) +#include "esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h" +#define ESPHOME_BLE_HUB_TYPE ln882h_ble_tracker::LN882HBLETracker +#endif +// No #else on purpose: builds without a tracker (host unit tests) get no BLEHub. + +namespace esphome::ble_device_base { + +#ifdef ESPHOME_BLE_HUB_TYPE +using BLEHub = ESPHOME_BLE_HUB_TYPE; +static_assert(BLEHubContract, "The build's BLE tracker is missing part of the BLEHub surface (ble_hub.h)"); +#undef ESPHOME_BLE_HUB_TYPE +#endif + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp new file mode 100644 index 0000000000..2c0d766683 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -0,0 +1,153 @@ +#include "scan_response_merger.h" + +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +void ScanResponseMerger::deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, bool raw_only) { + // A partial bind is treated as unbound; never dereference half a binding. + if (this->dispatcher_ == nullptr || this->scan_continuous_ == nullptr) + return; + this->dispatcher_->dispatch(mac, rssi, addr_type, data, data_len, raw_only, + *this->scan_continuous_ ? nullptr : this->log_tag_); +} + +void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len, uint32_t now) { + // One pass: find a same-device entry (deliver + reuse) while remembering the + // first free slot as the fallback. + PendingAdv *slot = nullptr; + PendingAdv *free_slot = nullptr; + for (auto &p : this->pending_adv_) { + if (!p.used) { + if (free_slot == nullptr) + free_slot = &p; + continue; + } + if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { + // Same device advertised again before its scan response arrived — deliver + // the previous advertisement (its scan response is not coming) and reuse + // the slot, so no frame is ever lost. + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + slot = &p; + break; + } + } + if (slot == nullptr) + slot = free_slot; + if (slot == nullptr) { + // Table full — degrade gracefully: deliver the advertisement unmerged. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/false); + return; + } + slot->used = true; + this->pending_count_++; + memcpy(slot->mac, mac, MAC_ADDRESS_SIZE); + slot->addr_type = addr_type; + slot->rssi = rssi; + slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); + memcpy(slot->data, data, slot->data_len); + slot->stored_ms = now; +} + +void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, + uint8_t data_len) { + // Fast-out on the empty table (sweep/flush use the same guard); this is the + // hottest caller. + if (this->pending_count_ != 0) { + for (auto &p : this->pending_adv_) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { + // Append in place: the slot is released on delivery, so its 62-byte + // buffer (legacy adv + scan response) holds the merged frame directly. + const uint8_t room = sizeof(p.data) - p.data_len; + const uint8_t add = (data_len <= room) ? data_len : room; + memcpy(p.data + p.data_len, data, add); + p.used = false; + this->pending_count_--; + // The advertisement's RSSI, not the scan response's (header contract). + this->deliver_(mac, p.rssi, addr_type, p.data, p.data_len + add, /*raw_only=*/false); + return; + } + } + } + // Unmatched scan-response: goes out on the raw callback only (HA merges per + // address); local listeners/triggers receive each advertisement exactly once + // via the merged/plain path above. + this->deliver_(mac, rssi, addr_type, data, data_len, /*raw_only=*/true); +} + +void ScanResponseMerger::sweep(uint32_t now) { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used && now - p.stored_ms > PENDING_ADV_TIMEOUT_MS) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void ScanResponseMerger::flush() { + if (this->pending_count_ == 0) + return; + for (auto &p : this->pending_adv_) { + if (p.used) { + p.used = false; + this->pending_count_--; + this->deliver_(p.mac, p.rssi, p.addr_type, p.data, p.data_len, /*raw_only=*/false); + } + } +} + +void AdvDispatcher::dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag) { + // Raw callback (the raw-advertisement path). Both full advertisements and + // unmatched scan responses (raw_only) are forwarded. + if (this->raw_callback_.is_set()) { + const RawAdvertisement adv{.address = mac_lsb_first_to_uint64(mac), + .data = data, + .data_len = data_len, + .rssi = rssi, + .addr_type = addr_type}; + this->raw_callback_.invoke(adv); + } + +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Scan-response-only frames are never parsed for local sensors/triggers. + if (raw_only) + return; + ESPBTDevice device; + device.from_scan_result(mac, rssi, addr_type, data, data_len); + // The listener list holds sensors AND the tracker's automation triggers + // (the triggers are listeners, exactly like esp32_ble_tracker), so one + // loop feeds both and ORs into `found`. + bool found = false; + for (auto *listener : this->listeners_) { + if (listener->parse_device(device)) { + found = true; + } + } + if (!found && log_unclaimed_tag != nullptr) + this->discovered_log_.log_device(log_unclaimed_tag, device); +#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT +} + +void AdvDispatcher::on_scan_end() { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->listeners_) + listener->on_scan_end(); + this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) +#endif +} + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h new file mode 100644 index 0000000000..f28790f207 --- /dev/null +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -0,0 +1,152 @@ +// Shared support for trackers whose controller delivers advertisement and +// scan response as SEPARATE reports (ln882h, rp2, bk72xx; ESP-IDF concatenates +// both into one result before ESPHome sees it): +// +// ScanResponseMerger — Bluedroid-style merge: a scannable advertisement is +// held briefly, its scan response is appended on arrival and the pair is +// delivered as ONE merged frame. Merged delivery is what the receiving side +// is built around: Home Assistant keeps the latest raw frame per device and +// skips re-parsing when it is unchanged — split delivery alternates two raw +// frames per device and defeats both. +// +// AdvDispatcher — the delivery half every such tracker repeats: raw +// callback, listener parsing, discovered-device log. Trackers delegate +// their BLEHub register_listener / set_raw_advertisement_callback here. +// +// The merger delivers straight into the tracker's AdvDispatcher — bind() wires +// the pair once in setup(). Single-task use only (every tracker calls this on +// the ESPHome main task). The clock is caller-provided: pass the same clock to +// stash_adv() and sweep() (millis() or App.get_loop_component_start_time(), +// never mixed). + +#pragma once + +#include "esphome/core/defines.h" + +// Emitted (cg.add_define) by each tracker that adopts the merger, so builds +// whose tracker merges in-stack (esp32) never compile this code. +#ifdef USE_BLE_SCAN_RESPONSE_MERGER + +#include "ble_device.h" +#include "ble_hub.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ble_device_base { + +/// The delivery half of a split-report tracker, shared so the dispatch +/// contract (raw-callback ordering, raw_only gate, discovered-log policy) +/// lives in one place. Owns the members every tracker otherwise duplicates; +/// the tracker's BLEHub methods delegate here. +class AdvDispatcher { + public: + void register_listener(ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + this->listeners_.push_back(listener); +#endif + } + void set_raw_advertisement_callback(RawAdvertisementCallback callback) { this->raw_callback_ = callback; } + /// Dispatch one (possibly merged) advertisement: the raw callback, and — + /// unless raw_only — parsing for listeners/triggers. raw_only marks + /// unmatched scan-response frames: forwarded on the raw callback only, never + /// parsed for local sensors/triggers (Home Assistant merges per address). + /// log_unclaimed_tag: when non-null, a device no listener claimed is logged + /// under this tag (esp32_ble_tracker parity: pass the tracker TAG on + /// one-shot scans, nullptr on continuous scans, which would spam). + void dispatch(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only, const char *log_unclaimed_tag); + /// Fire listeners' on_scan_end and reset the per-scan discovered-log dedup. + void on_scan_end(); + + protected: + RawAdvertisementCallback raw_callback_{}; +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Parsed-advertisement consumers registered through ble_device_base. + // Codegen-sized: no heap allocation, no std::vector template instantiations. + StaticVector listeners_; + // Per-period "Found device" DEBUG log with MAC dedup. Guarded like its only + // writer so a no-listener build does not carry an unused vector. + DiscoveredDeviceLog discovered_log_{}; +#endif +}; + +class ScanResponseMerger { + public: + /// Wire the merger's output; call once in the tracker's setup(). Every + /// delivered frame goes to dispatcher->dispatch(); scan_continuous is read + /// at each delivery (runtime continuous flips are honored) to decide the + /// unclaimed-device log tag, so both pointers must outlive the merger — + /// tracker members always do. + void bind(AdvDispatcher *dispatcher, const bool *scan_continuous, const char *log_tag) { + this->dispatcher_ = dispatcher; + this->scan_continuous_ = scan_continuous; + this->log_tag_ = log_tag; + } + /// Hold a scannable advertisement, waiting for its scan response. The + /// tracker calls this only when it wants the merge (scannable advertisement + /// while an active scan runs) and delivers everything else directly. A + /// same-device re-advertisement delivers the held frame (its scan response + /// is not coming) and reuses the slot; a full table degrades gracefully to + /// unmerged delivery. + void stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + uint32_t now); + /// A scan response arrived: append it to the held advertisement from the + /// same device and deliver the pair as one frame. The merged frame reports + /// the ADVERTISEMENT's RSSI — every unmerged path reports the + /// advertisement's measurement, so a device's RSSI must not jump between two + /// measurements depending on merge timing. Unmatched responses are delivered + /// raw_only. + void submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len); + /// Timeout flush (call from loop() with the stash_adv() clock): deliver + /// held advertisements whose scan response never arrived (device didn't + /// answer / frame lost) — unmerged, past PENDING_ADV_TIMEOUT_MS. + void sweep(uint32_t now); + /// Deliver every held advertisement now (scan period/scan is ending, before + /// on_scan_end fires): unmerged delivery, same as the timeout path. + void flush(); + /// Lets loop() skip the cross-TU sweep() call in the common case (empty: + /// passive scan, or every pair already matched). + bool empty() const { return this->pending_count_ == 0; } + + private: + /// All delivery funnels through here: an unbound merger (bind() not called) + /// drops the frame instead of jumping through a null pointer, mirroring the + /// guard-before-invoke convention of the ble_hub.h callback slots. + void deliver_(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint8_t data_len, + bool raw_only); + + // 62 bytes = legacy adv (31) + scan response (31), the same merged maximum + // as ESP-IDF delivers on ESP32. + struct PendingAdv { + bool used{false}; + uint8_t mac[MAC_ADDRESS_SIZE]; + uint8_t addr_type; + int8_t rssi; + uint8_t data_len; // <= sizeof(data) + uint8_t data[62]; + uint32_t stored_ms; + }; + // Sized for the unanswered case: a pair that IS answered normally matches + // within one report-queue drain, so a slot is held for the full timeout only + // by scannable devices that never reply. 8 concurrent such advertisers + // before the merge degrades (frames still delivered, just unmerged) at + // ~80 B each. + static constexpr size_t MAX_PENDING_ADV = 8; + // On air a scan response follows its advertisement by T_IFS (150 µs) — the + // timeout only covers HOST-side report queuing under WiFi/BLE coexistence, + // measured on-device (ln882h) at up to ~136 ms. 300 ms = >2x that margin, + // while staying below any device's re-advertising period. + static constexpr uint32_t PENDING_ADV_TIMEOUT_MS = 300; + AdvDispatcher *dispatcher_{nullptr}; + const bool *scan_continuous_{nullptr}; // read at delivery; see bind() + const char *log_tag_{nullptr}; + // pending_count_ mirrors the number of set `used` flags; both are updated + // together on every transition. + PendingAdv pending_adv_[MAX_PENDING_ADV]; + uint8_t pending_count_{0}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_SCAN_RESPONSE_MERGER 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/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index 3a0f1ade98..a43d3561f8 100644 --- a/esphome/components/ble_presence/binary_sensor.py +++ b/esphome/components/ble_presence/binary_sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble_tracker +from esphome.components import binary_sensor, ble_device_base import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -10,21 +10,22 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TIMEOUT, ) +from esphome.types import ConfigType CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_presence_ns = cg.esphome_ns.namespace("ble_presence") BLEPresenceDevice = ble_presence_ns.class_( "BLEPresenceDevice", binary_sensor.BinarySensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_IBEACON_MAJOR in config and CONF_IBEACON_UUID not in config: raise cv.Invalid("iBeacon major identifier requires iBeacon UUID") if CONF_IBEACON_MINOR in config and CONF_IBEACON_UUID not in config: @@ -33,23 +34,24 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_presence"), binary_sensor.binary_sensor_schema(BLEPresenceDevice) .extend( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_TIMEOUT, default="5min"): cv.positive_time_period, cv.Optional(CONF_MIN_RSSI): cv.All( cv.decibel, cv.int_range(min=-100, max=-30) ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -57,10 +59,10 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds)) if min_rssi := config.get(CONF_MIN_RSSI): @@ -70,20 +72,15 @@ async def to_code(config): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index 4a70648ac5..bc169623ce 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -1,8 +1,6 @@ #include "ble_presence_device.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_presence { static const char *const TAG = "ble_presence"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_presence"; void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h index 76e8079948..4e49cc32a3 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -1,16 +1,18 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/binary_sensor/binary_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. 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 ble_device_base::ESPBTDeviceListener, + public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; @@ -22,19 +24,19 @@ class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -49,7 +51,7 @@ class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, this->minimum_rssi_ = rssi; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) { return false; } @@ -119,9 +121,9 @@ class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_{0}; uint16_t ibeacon_minor_{0}; @@ -137,5 +139,3 @@ class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff, }; } // namespace esphome::ble_presence - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp index f678865f47..7c7c7b2148 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -1,8 +1,6 @@ #include "ble_rssi_sensor.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_rssi { static const char *const TAG = "ble_rssi"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_rssi"; void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h index a876fa51d2..a30b94b8b7 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.h +++ b/esphome/components/ble_rssi/ble_rssi_sensor.h @@ -1,14 +1,16 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. namespace esphome::ble_rssi { -class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BLERSSISensor final : public sensor::Sensor, public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->match_by_ = MATCH_BY_MAC_ADDRESS; @@ -20,19 +22,19 @@ class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDevi } void set_service_uuid16(uint16_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint16(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); } void set_service_uuid32(uint32_t uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_uint32(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); } void set_service_uuid128(uint8_t *uuid) { this->match_by_ = MATCH_BY_SERVICE_UUID; - this->uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_uuid(uint8_t *uuid) { this->match_by_ = MATCH_BY_IBEACON_UUID; - this->ibeacon_uuid_ = esp32_ble_tracker::ESPBTUUID::from_raw(uuid); + this->ibeacon_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); } void set_ibeacon_major(uint16_t major) { this->check_ibeacon_major_ = true; @@ -47,7 +49,7 @@ class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDevi this->publish_state(NAN); this->found_ = false; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { switch (this->match_by_) { case MATCH_BY_MAC_ADDRESS: if (device.address_uint64() == this->address_) { @@ -109,9 +111,9 @@ class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDevi uint64_t address_; uint8_t *irk_; - esp32_ble_tracker::ESPBTUUID uuid_; + ble_device_base::ESPBTUUID uuid_; - esp32_ble_tracker::ESPBTUUID ibeacon_uuid_; + ble_device_base::ESPBTUUID ibeacon_uuid_; uint16_t ibeacon_major_; uint16_t ibeacon_minor_; @@ -120,5 +122,3 @@ class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDevi }; } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index c4e767aa21..6813505d58 100644 --- a/esphome/components/ble_rssi/sensor.py +++ b/esphome/components/ble_rssi/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_IBEACON_MAJOR, @@ -11,18 +11,19 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_DECIBEL_MILLIWATT, ) +from esphome.types import ConfigType CONF_IRK = "irk" -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi") BLERSSISensor = ble_rssi_ns.class_( - "BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener + "BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener ) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_IBEACON_MAJOR in config and CONF_IBEACON_UUID not in config: raise cv.Invalid("iBeacon major identifier requires iBeacon UUID") if CONF_IBEACON_MINOR in config and CONF_IBEACON_UUID not in config: @@ -31,6 +32,7 @@ def _validate(config): CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_rssi"), sensor.sensor_schema( BLERSSISensor, unit_of_measurement=UNIT_DECIBEL_MILLIWATT, @@ -42,14 +44,14 @@ CONFIG_SCHEMA = cv.All( { cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_IRK): cv.uuid, - cv.Optional(CONF_SERVICE_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_SERVICE_UUID): ble_device_base.bt_uuid, cv.Optional(CONF_IBEACON_MAJOR): cv.uint16_t, cv.Optional(CONF_IBEACON_MINOR): cv.uint16_t, - cv.Optional(CONF_IBEACON_UUID): esp32_ble_tracker.bt_uuid, + cv.Optional(CONF_IBEACON_UUID): ble_device_base.bt_uuid, } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), cv.has_exactly_one_key( CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID ), @@ -57,29 +59,24 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) if mac_address := config.get(CONF_MAC_ADDRESS): cg.add(var.set_address(mac_address.as_hex)) if irk := config.get(CONF_IRK): - irk = esp32_ble_tracker.as_hex_array(str(irk)) + ble_device_base.request_irk_support() + irk = ble_device_base.as_hex_array(str(irk)) cg.add(var.set_irk(irk)) if service_uuid := config.get(CONF_SERVICE_UUID): - if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): - cg.add(var.set_service_uuid16(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid32_format): - cg.add(var.set_service_uuid32(esp32_ble_tracker.as_hex(service_uuid))) - elif len(service_uuid) == len(esp32_ble_tracker.bt_uuid128_format): - uuid128 = esp32_ble_tracker.as_reversed_hex_array(service_uuid) - cg.add(var.set_service_uuid128(uuid128)) + ble_device_base.add_service_uuid(var, service_uuid) if ibeacon_uuid := config.get(CONF_IBEACON_UUID): - ibeacon_uuid = esp32_ble_tracker.as_reversed_hex_array(ibeacon_uuid) + ibeacon_uuid = ble_device_base.as_reversed_hex_array(ibeacon_uuid) cg.add(var.set_ibeacon_uuid(ibeacon_uuid)) if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index d85894edc8..3d7a301793 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -1,8 +1,6 @@ #include "ble_scanner.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::ble_scanner { static const char *const TAG = "ble_scanner"; @@ -10,5 +8,3 @@ static const char *const TAG = "ble_scanner"; void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index c2d48741b1..0efc42682b 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -5,33 +5,25 @@ #include #include "esphome/core/component.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/text_sensor/text_sensor.h" -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform, +// and this component is only compiled when configured — which requires a BLE +// hub — so it builds on any platform with a BLEHub tracker without a per-chip +// guard. 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 ble_device_base::ESPBTDeviceListener, public Component { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override { + bool parse_device(const ble_device_base::ESPBTDevice &device) override { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - // Escape special characters in the device name for valid JSON - const char *name = device.get_name().c_str(); + // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this + // sensor has always published. char escaped_name[128]; - size_t pos = 0; - for (; *name != '\0' && pos < sizeof(escaped_name) - 7; name++) { - uint8_t c = static_cast(*name); - if (c == '"' || c == '\\') { - escaped_name[pos++] = '\\'; - escaped_name[pos++] = c; - } else if (c < 0x20) { - pos += snprintf(escaped_name + pos, sizeof(escaped_name) - pos, "\\u%04x", c); - } else { - escaped_name[pos++] = c; - } - } - escaped_name[pos] = '\0'; + json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false); char buf[256]; snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", @@ -43,5 +35,3 @@ class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESP }; } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/text_sensor.py b/esphome/components/ble_scanner/text_sensor.py index 96d71a0399..0c60b53783 100644 --- a/esphome/components/ble_scanner/text_sensor.py +++ b/esphome/components/ble_scanner/text_sensor.py @@ -1,25 +1,27 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, text_sensor +from esphome.components import ble_device_base, text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner") BLEScanner = ble_scanner_ns.class_( "BLEScanner", text_sensor.TextSensor, cg.Component, - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, ) CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("ble_scanner"), text_sensor.text_sensor_schema(BLEScanner) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py new file mode 100644 index 0000000000..fa1a86be3a --- /dev/null +++ b/esphome/components/bluetooth_connection/__init__.py @@ -0,0 +1,203 @@ +"""Per-platform GATT connection backends and the helpers to embed one. + +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +import esphome.codegen as cg +from esphome.components import rp2040_ble +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType + + +def AUTO_LOAD() -> list[str]: + """ble_device_base plus the platform BLE stack the build's backend + registers with (the Bluedroid header includes the tracker's), so + consumers need not know. The platform-less arm serves manifest tooling.""" + if CORE.is_esp32: + return ["ble_device_base", "esp32_ble_tracker"] + if CORE.is_rp2: + return ["ble_device_base", "rp2040_ble"] + if CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"] + return ["ble_device_base"] + + +CODEOWNERS = ["@bdraco", "@jesserockz"] + +bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") + +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and +# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's +# btstack_memory.cpp replaces those pools via linker --wrap (requested by +# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself +# belongs to the platform stack that owns the pools. +RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS + +# Slot limits for the hub platforms running the connection-capable proxy; +# the backend registry itself is _PLATFORM_BACKENDS below. +HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} + +# The hub-platform wrapper and the backend codegen classes. +HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") +RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) +BluedroidGattClient = bluetooth_connection_ns.class_( + "BluedroidGattClient", cg.Component +) + +CONF_BACKEND_ID = "backend_id" + +DOMAIN = "bluetooth_connection" + + +@dataclass +class _ConnectionData: + rp2_backend_count: int = 0 + + +def _get_data() -> _ConnectionData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _ConnectionData() + return CORE.data[DOMAIN] + + +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker + + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA + + +def _rp2_schema_fragment() -> cv.Schema: + return cv.Schema( + {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} + ) + + +async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import esp32_ble_tracker + + # The tracker's promote loop owns connect timing; the backend registers + # as a raw client (it is the tracker's ESPBTClient). + await esp32_ble_tracker.register_raw_client(backend, config) + + +async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import ota + + # The backend drops its link when an OTA starts (esp32 tracker parity). + ota.request_ota_state_listeners() + # More than one backend outgrows the prebuilt BTstack pools: swap them for + # the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's + # btstack_memory.cpp. Keyed to backend registrations (the same event that + # grows the count that sizes the pools), so single-backend builds emit no + # flags and stay byte-identical to previous releases. + data = _get_data() + data.rp2_backend_count += 1 + if data.rp2_backend_count == 2: + rp2040_ble.add_btstack_pool_overrides() + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + + +@dataclass(frozen=True) +class _PlatformBackend: + """One platform's backend: codegen class, extra schema keys, and stack + registration. The esp32 fragments import their stack lazily because those + imports register esp32-only automations as a side effect; rp2040_ble is + side-effect-free, so it is imported at module scope (the cap constant + needs it there anyway).""" + + backend_class: cg.MockObjClass + schema_fragment: Callable[[], cv.Schema] + register: Callable[[cg.MockObj, ConfigType], Awaitable[None]] + + +# The single registry of platforms with a GATT client backend; a platform +# missing here fails loudly everywhere instead of falling into another +# platform's arm. +_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { + PLATFORM_ESP32: _PlatformBackend( + BluedroidGattClient, _esp32_schema_fragment, _esp32_register + ), + PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register), +} + + +def _backend_entry(platform: str | None = None) -> _PlatformBackend: + key = platform if platform is not None else CORE.target_platform + if (entry := _PLATFORM_BACKENDS.get(key)) is None: + raise cv.Invalid(f"no GATT client backend is registered for {key}") + return entry + + +def gatt_client_schema(platform: str | None = None) -> cv.Schema: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. + + Defaults to the platform being validated; pass `platform` explicitly when + building a schema outside validation (the language-schema dumper calls + per-platform builders under arbitrary CORE platforms). + """ + entry = _backend_entry(platform) + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} + ) + + +def hub_connection_schema(platform: str | None = None) -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment, plus the component keys (setup_priority and + friends now apply to the backend, the slot's real Component). Same + platform rules as gatt_client_schema().""" + return ( + gatt_client_schema(platform) + .extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + # The backend is the slot's real Component: component keys from the + # connection entry (setup_priority, ...) apply to it. Consumers whose own + # schema carries keys that register_component would misapply to the + # backend (e.g. a polling interval) must not put them in this config. + await cg.register_component(backend, config) + await _backend_entry().register(backend, config) + return backend + + +# Named so tests can pin the hub entry against bluetooth_proxy's platform +# list (this module cannot import bluetooth_proxy to derive it). +SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { + "bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]), + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, +} + +FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp new file mode 100644 index 0000000000..a001729083 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -0,0 +1,68 @@ +#include "bluetooth_connection.h" + +#ifdef USE_ESP32 +#include +#include +#endif + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + +#include "esphome/components/api/api_pb2.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str) { + // Calculate the actual size of just this service (+1 for the field tag) + size_t service_size = resp.services.back().calculate_size() + 1; + + if (current_size + service_size > MAX_PACKET_SIZE) { + if (resp.services.size() > 1) { + // We would go over -- pop the last service and retry it in the next batch + resp.services.pop_back(); + ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %u + service: %u > %u), sending current batch", + connection_index, address_str, send_service, (unsigned) current_size, (unsigned) service_size, + (unsigned) MAX_PACKET_SIZE); + // Don't advance send_service -- the popped service goes into the next batch + } else { + // This single service is too large, but we have to send it anyway; + // advance so we don't get stuck + ESP_LOGW(TAG, "[%d] [%s] Service %d is too large (%u bytes) but sending anyway", connection_index, address_str, + send_service, (unsigned) service_size); + send_service++; + } + return BatchClose::SEND; + } + + current_size += service_size; + send_service++; + return BatchClose::CONTINUE; +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +namespace esphome::bluetooth_connection { + +// Address-scoped Bluedroid maintenance. Gated with the connection surface: +// the advertisement-only arm no longer dispatches these requests at all. + +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_remove_bond_device(bda); +} + +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_gattc_cache_clean(bda); +} + +} // namespace esphome::bluetooth_connection +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h new file mode 100644 index 0000000000..b21d997b4f --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -0,0 +1,169 @@ +// Shared types and helpers for the per-platform GATT connection backends and +// the Bluetooth proxy that drives them. + +#pragma once + +#include "esphome/core/defines.h" + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include +#include +#include + +#ifdef USE_ESP32 +#include +#endif + +// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has +// proxy connection slots": codegen emits it per configured slot, and each +// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not +// the converse: a backend can exist without proxy slots). The hub +// wrapper, the proxy's connection surface and the API's connection messages +// all gate on it. The address-scoped maintenance functions below are only +// reached from that gated surface; the #else stubs just keep this header +// parsing on arms without a backend. + +namespace esphome::api { +class BluetoothGATTGetServicesResponse; +} // namespace esphome::api + +namespace esphome::bluetooth_connection { + +// Connection-owned error type for the API error fields, which are plain +// integers on the wire. Aliases esp_err_t on esp32 (where the values come from +// IDF calls); a bare int elsewhere. Owning the name instead of probing for +// esp_err_t keeps the header independent of how a platform's SDK spells its +// error type. +#ifdef USE_ESP32 +using conn_err_t = esp_err_t; +static constexpr conn_err_t CONN_OK = ESP_OK; +#else +using conn_err_t = int; +static constexpr conn_err_t CONN_OK = 0; +#endif + +// The ESPHome-private "not connected" wire value, shared with the neutral +// GATT contract so backend and wrapper cannot drift. +static constexpr conn_err_t GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; + +// What the platform's connection backend supports beyond GATT operations; +// the proxy derives its feature flags and legacy version from these. +#if defined(USE_ESP32) +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = true; +#elif defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) +// The rp2 BTstack backend pairs (just works + bonding); it has no service +// cache to clear. Keyed on the backend, not the generic client define, so a +// future backend without pairing keeps the stub arm below. +static constexpr bool SUPPORTS_PAIRING = true; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#else +static constexpr bool SUPPORTS_PAIRING = false; +static constexpr bool SUPPORTS_CACHE_CLEARING = false; +#endif + +// Address-scoped (not connection-scoped) maintenance requests. +#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT) +conn_err_t unpair_device(uint64_t address); +#else +inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } +#endif +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +conn_err_t clear_gatt_cache(uint64_t address); +#else +inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } +#endif + +// send_service_ cursor states; >= 0 is the next service index to stream. +static constexpr int DONE_SENDING_SERVICES = -2; +static constexpr int INIT_SENDING_SERVICES = -3; +static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed +// Every sentinel must stay below the >= 0 streaming gate and clear of +// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused. +static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0); +static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED && + SERVICES_DONE_PENDING != GATT_NOT_CONNECTED); +// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done +// delivered near the client's 30 s timeout could land on a fresh request's +// empty accumulator and cache as an empty database. +static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; +// Owed-ack retries stop after ~25 s of subscribed drain time from the first +// refusal, keeping most of the client's 30 s GATT window for congestion to +// clear while still bounding how stale a delivered reply can be. +static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250; + +// ---- Service-streaming size budget, shared by every platform's streamer ---- + +// Conservative MTU limit for API messages (accounts for WPA3 overhead) +static constexpr size_t MAX_PACKET_SIZE = 1360; + +// Constants for size estimation +static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) +static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) +static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) +static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic + +/// Estimate the wire size of a service (service overhead + its characteristics, +/// assuming 128-bit UUIDs and one 128-bit descriptor per characteristic to be +/// safe) before fetching/packing the full data. +inline size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { + size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; + return service_overhead + (CHAR_SIZE_128BIT + DESC_SIZE_128BIT * DESC_PER_CHAR) * char_count; +} + +// ---- UUID wire packing, shared by every platform's streamer ---- + +// This function is allocation-free and directly packs UUIDs into the output +// array using precalculated constants for the Bluetooth base UUID. ESPBTUUID +// stores its 128-bit form little-endian (same as Bluedroid). +inline void fill_128bit_uuid_array(std::array &out, const ble_device_base::ESPBTUUID &uuid) { + using ble_device_base::ESPBTUUID; + if (uuid.type() == ESPBTUUID::Type::UUID128) { + const uint8_t *u = uuid.uuid128(); + // out[0] = bytes 8-15 (big-endian), out[1] = bytes 0-7 (big-endian) + out[0] = ((uint64_t) u[15] << 56) | ((uint64_t) u[14] << 48) | ((uint64_t) u[13] << 40) | ((uint64_t) u[12] << 32) | + ((uint64_t) u[11] << 24) | ((uint64_t) u[10] << 16) | ((uint64_t) u[9] << 8) | ((uint64_t) u[8]); + out[1] = ((uint64_t) u[7] << 56) | ((uint64_t) u[6] << 48) | ((uint64_t) u[5] << 40) | ((uint64_t) u[4] << 32) | + ((uint64_t) u[3] << 24) | ((uint64_t) u[2] << 16) | ((uint64_t) u[1] << 8) | ((uint64_t) u[0]); + return; + } + // 16/32-bit UUID inserted into the Bluetooth base UUID: + // 00000000-0000-1000-8000-00805F9B34FB + uint32_t value = uuid.type() == ESPBTUUID::Type::UUID16 ? uuid.uuid16() : uuid.uuid32(); + out[0] = ((uint64_t) value << 32) | 0x00001000ULL; // Base UUID bytes 8-11 + out[1] = 0x800000805F9B34FBULL; // Base UUID bytes 0-7 +} + +/// Fill the UUID in the appropriate wire format based on client support and +/// UUID type (128-bit array for old clients or 128-bit UUIDs, short form +/// otherwise). +inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, + const ble_device_base::ESPBTUUID &uuid, bool use_efficient_uuids) { + using ble_device_base::ESPBTUUID; + if (!use_efficient_uuids || uuid.type() == ESPBTUUID::Type::UUID128) { + fill_128bit_uuid_array(uuid_128, uuid); + } else if (uuid.type() == ESPBTUUID::Type::UUID16) { + short_uuid = uuid.uuid16(); + } else { + short_uuid = uuid.uuid32(); + } +} + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +/// Result of close_service_batch: keep filling the batch or send it now. +/// An oversized service is packed alone; a failed (backpressured) send is +/// retried from the batch start, so no service is silently skipped. +enum class BatchClose : uint8_t { CONTINUE, SEND }; + +/// Close out the service just packed into resp (account its actual wire size, +/// advance the cursor) and decide whether the batch must be sent now. Shared +/// tail of both platform streamers so the budget logic and its log lines +/// cannot drift. +BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, + uint8_t connection_index, const char *address_str); +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +} // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..15f854239d --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,772 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +#include "bluetooth_connection.h" +#include "bluetooth_connection_hub.h" + +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#endif + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; +using esp32_ble_tracker::ClientState; +using esp32_ble_tracker::ConnectionType; + +// ---- tracker surface ---- + +void BluedroidGattClient::connect() { this->tracker_connect_(); } +void BluedroidGattClient::disconnect() { this->gatt_disconnect(); } + +// ---- component ---- + +void BluedroidGattClient::setup() { + static uint8_t connection_index = 0; + this->connection_index_ = connection_index++; +} + +void BluedroidGattClient::loop() { + if (!esp32_ble::global_ble->is_active()) { + // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer + // frees its slot, then re-register the app on the next enable. + auto down_st = this->state(); + if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + this->set_state(ClientState::INIT); + return; + } + auto st = this->state(); + if (st == ClientState::INIT) { + // Parity with BLEClientBase: a failed registration marks the slot + // failed and idles it without retry. + auto ret = esp_ble_gattc_app_register(this->app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_idle_(); + } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { + // The one teardown safety net: a lost CLOSE_EVT, or a scheduled + // teardown whose OPEN_EVT never arrives. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); + // Release before idling: a lost completion must not leak the cache. + this->release_services(); + this->set_idle_(); // also clears want_disconnect_ + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); + } + } else { + // The loop stays on while a link exists (stack-down watch, pre-started + // search flush); it settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +void BluedroidGattClient::dump_config() { + ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); + if (this->is_failed()) { + ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); + } +} + +// ---- contract ops ---- + +int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + // Only from idle: clobbering DISCONNECTING would open a new link the + // stale CLOSE_EVT then tears down. + if (this->state() != ClientState::IDLE) { + ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_); + return ESP_GATT_BUSY; + } + ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_); + this->remote_addr_type_ = addr_type; + // Hand the request to the tracker's promote loop: it stops the scan, raises + // coex, and calls tracker_connect_() - the tracker owns connect timing here. + this->set_state(ClientState::DISCOVERED); + return 0; +} + +void BluedroidGattClient::tracker_connect_() { + auto st = this->state(); + if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_); + return; + } + if (st == ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_); + return; + } + ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_); + // Per-attempt latches; the search machine is reset by set_idle_(), the + // one door back to IDLE. + this->services_released_ = false; + this->seen_mtu_ = false; + this->mtu_failed_ = false; + this->enable_loop(); + this->set_state(ClientState::CONNECTING); + if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { + // Fast params for the discovery phase; stepped down at SEARCH_CMPL. + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, + FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT)); + } else { + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, + MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT)); + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, + static_cast(this->remote_addr_type_), true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired; nothing to close. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ret); + } +} + +int BluedroidGattClient::gatt_disconnect() { + auto st = this->state(); + if (st == ClientState::DISCONNECTING) { + return 0; + } + // Nothing was opened, so no completion event will follow: report + // not-connected and the hub frees the slot at once (rp2 convention). + if (st == ClientState::IDLE) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::DISCOVERED) { + // Parked for the tracker promote loop, never opened. + this->set_idle_(); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_); + this->want_disconnect_ = true; + // Arm the safety window: a lost OPEN_EVT must not leak the teardown. + this->disconnecting_started_ = millis(); + this->enable_loop(); + return 0; + } + this->unconditional_disconnect_(); + return 0; +} + +void BluedroidGattClient::unconditional_disconnect_() { + ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); + if (this->conn_id_ == UNSET_CONN_ID) { + // Terminal state now rather than leaning on the scheduled-teardown timer. + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + return; + } + auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); + if (err != ESP_OK) { + // The stack is now in an indeterminate state for this link. + ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err); + } + this->set_disconnecting_(); +} + +bool BluedroidGattClient::cancel_gatt_disconnect() { + // Only a scheduled teardown (want_disconnect_ latched while the open is + // still in flight) is cancellable; once closing started the terminal + // report settles the race. + if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { + return false; + } + this->want_disconnect_ = false; + return true; +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + switch (this->search_state_) { + case SearchState::PRESTARTED: + // The pending SEARCH_CMPL reports once it lands. + this->search_state_ = SearchState::CLAIMED; + return 0; + case SearchState::PRESTART_DONE: + // Already landed: the flush after the connected report delivers + // (loop() covers a claim made outside that event drain). + this->search_state_ = SearchState::REPORT_PENDING; + this->enable_loop(); + return 0; + case SearchState::CLAIMED: + case SearchState::REPORT_PENDING: + return 0; // One completion is already owed to this claimant. + case SearchState::NONE: + break; + } + int err = this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr)); + if (err == 0) { + this->search_state_ = SearchState::CLAIMED; + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, + handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // The BTC layer copies the payload immediately, so the const_cast is safe. + return this->check_and_log_error_( + "esp_ble_gattc_write_char", + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::read_descriptor(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_read_char_descr", + esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_write_char_descr", + esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // Local registration only; the CCCD write is the API client's responsibility. + if (enable) { + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", + esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle)); + } + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", + esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle)); +} + +int BluedroidGattClient::pair() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); +} + +int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); +} + +void BluedroidGattClient::release_services() { + this->service_total_ = 0; + // Always set: terminates any in-flight stream on every cache config. + this->services_released_ = true; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // A failed clean leaves a stale database the next connection could serve + // as authoritative. A disabled stack invalidates its own cache; skip the + // meaningless call instead of warning on every OTA/ble.disable teardown. + if (esp32_ble::global_ble->is_active()) { + this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_)); + } +#endif +} + +// ---- internals ---- + +bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { + return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; +} + +void BluedroidGattClient::set_idle_() { + this->set_state(ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + this->search_state_ = SearchState::NONE; + this->search_status_ = 0; +} + +void BluedroidGattClient::set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(ClientState::DISCONNECTING); + // The loop may be disabled while idle; the safety timeout needs it. + this->enable_loop(); +} + +esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type); + return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params)); +} + +int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_gattc_warning_(operation, err); + } + return err; +} + +void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) { + ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code); +} + +// ---- service streaming ---- + +int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + if (status != ESP_GATT_OK) { + // A failed discovery reads as a clean zero from the count calls below; + // honoring the event status stops it becoming an authoritative empty + // list. + return status; + } + uint16_t primary = 0; + uint16_t secondary = 0; + auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE, + 0x0001, 0xFFFF, 0, &primary); + auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, + 0x0001, 0xFFFF, 0, &secondary); + if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) { + // A failed count must not become an authoritative empty database. + auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status); + return count_status; + } + this->service_total_ = primary + secondary; + return 0; +} + +// Reports a completed search once claimed; delivery consumes the state so +// a re-discovery issues a real search. +void BluedroidGattClient::deliver_pending_search_() { + if (this->search_state_ != SearchState::REPORT_PENDING) + return; + this->search_state_ = SearchState::NONE; + this->listener_->on_service_discovery_done(this->search_status_); +} + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +// The wrapper's compile-time streamer detection must keep finding this +// method; a signature drift would silently fall back to the table streamer, +// which proxy builds compile without a materializer. +static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); + +// Bound by the SERVICE STREAMING HAZARD note at the top of +// bluetooth_connection_hub.cpp: never skip a batch, never send done early. +void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { + if (this->services_released_) { + // Released under the stream: park without services-done so a partial + // list is never cached as authoritative (the client retries after its + // GetServices timeout). + ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + return; + } + if (conn.send_service_ >= this->service_total_) { + this->release_services(); + conn.send_services_done_(); + return; + } + + // The subscriber vanished mid-stream. + auto *api_conn = conn.proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); + conn.park_service_stream_(); + return; + } + + bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids(); + api::BluetoothGATTGetServicesResponse resp; + resp.address = conn.address_; + size_t current_size = resp.calculate_size(); + int16_t batch_start = conn.send_service_; + + while (conn.send_service_ < this->service_total_) { + esp_gattc_service_elem_t service_result; + uint16_t svc_count = 1; + esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, + &svc_count, conn.send_service_); + if (svc_status != ESP_GATT_OK || svc_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_, + conn.address_str_, conn.send_service_); + conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND); + return; + } + uint16_t total_char_count = 0; + auto char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); + if (char_count_status != ESP_GATT_OK) { + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status); + conn.abort_service_stream(char_count_status); + return; + } + + // If this service likely won't fit, send the current batch first. + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && current_size + estimated_size > MAX_PACKET_SIZE) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); + service_resp.handle = service_result.start_handle; + + if (total_char_count > 0) { + service_resp.characteristics.init(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + // Bounded by the count query: a misbehaving peripheral can make the + // enumeration return more entries than it reported. + while (char_offset < total_char_count) { + uint16_t cc = 1; + auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (char_status != ESP_GATT_OK || cc == 0) { + // An early terminator contradicts the count from the same cache; + // never stream a silently truncated list. + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); + conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND); + return; + } + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + + uint16_t total_desc_count = 0; + auto desc_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, + 0, 0, char_result.char_handle, &total_desc_count); + if (desc_count_status != ESP_GATT_OK) { + // Abort rather than stream the characteristic descriptor-less: a + // missing CCCD in a cached database breaks notifications for good. + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status); + conn.abort_service_stream(desc_count_status); + return; + } + if (total_desc_count > 0) { + characteristic_resp.descriptors.init(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (desc_offset < total_desc_count) { + uint16_t dc = 1; + auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle, + &desc_result, &dc, desc_offset); + if (desc_status != ESP_GATT_OK || dc == 0) { + this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status); + conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND); + return; + } + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } + } + char_offset++; + } + } + + if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // On a failed send, rewind the cursor so the batch is retried instead of + // silently skipped. + if (!api_conn->send_message(resp)) { + conn.note_batch_stalled_(); + conn.send_service_ = batch_start; + return; + } + conn.batch_stalled_ = false; +} +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + auto st = this->state(); + if (st == ClientState::IDLE) { + // Late OPEN_EVT after the slot went IDLE (open-error race, or the + // teardown net gave up): close a won link, never resurrect the slot. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status); + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + // A failed close here leaks a live link nothing tracks; make it heard. + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + } + return; + } + if (st != ClientState::CONNECTING) { + ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_); + } + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + this->log_gattc_warning_("Connection open", param->open.status); + // Never established, CLOSE_EVT may not follow. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, param->open.status); + return; + } + if (this->disconnect_pending()) { + // Open resolved with a teardown scheduled: close now (conn_id_ stays set + // so CLOSE_EVT still matches). + this->unconditional_disconnect_(); + return; + } + this->set_state(ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_); + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + this->set_state(ClientState::ESTABLISHED); + // No discovery phase: report immediately with the default MTU. The + // cached path never waits for (or reports) the exchange - seen_mtu_ + // suppresses the CFG_MTU report, matching the previous esp32 behavior. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + } else { + // Discovery-bound connection: start the search now so it overlaps the + // MTU exchange. On a refusal fall back to the serialized path - the + // consumer's own discover_services() call retries the real search. + if (this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) { + this->search_state_ = SearchState::PRESTARTED; + } + if (this->mtu_failed_ && !this->seen_mtu_) { + // Refused MTU request: report with the default so the consumer + // proceeds. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + this->deliver_pending_search_(); + } + } +} + +void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) { + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_); + } else { + ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason); + } + if (this->state() == ClientState::IDLE) { + // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. + return; + } + // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting + // earlier makes the controller reject with 133 or assert) and before + // reporting - the wrapper frees the slot on the report, and a freed slot + // invites a reconnect into the still-closing link. + this->release_services(); + this->set_disconnecting_(); +} + +bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, + esp_ble_gattc_cb_param_t *param) { + if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) + return false; + if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) + return false; + + switch (event) { + case ESP_GATTC_REG_EVT: { + if (param->reg.status == ESP_GATT_OK) { + this->gattc_if_ = esp_gattc_if; + } else { + ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); + this->mark_failed(); + } + break; + } + case ESP_GATTC_CONNECT_EVT: { + if (!this->check_addr_(param->connect.remote_bda)) + return false; + this->conn_id_ = param->connect.conn_id; + // MTU request here rather than OPEN_EVT, matching the IDF examples. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); + // No CFG_MTU_EVT will follow; OPEN_EVT reports with the default. + this->mtu_failed_ = true; + } + break; + } + case ESP_GATTC_OPEN_EVT: { + if (!this->check_addr_(param->open.remote_bda)) + return false; + this->handle_open_evt_(param); + break; + } + case ESP_GATTC_CFG_MTU_EVT: { + if (this->conn_id_ != param->cfg_mtu.conn_id) + return false; + if (param->cfg_mtu.status != ESP_GATT_OK) { + // Warn only; a disconnect will follow if the link is dead. + this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status); + } + if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) { + // Teardown owns the link: suppress the connected report here like + // OPEN_EVT and SEARCH_CMPL do; the terminal report settles it. + this->seen_mtu_ = true; + // The connected report waited for the MTU; forwarded, not stored. + this->listener_->on_connection_state( + true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0); + // The consumer requests discovery from inside that report; when the + // pre-started search already finished, complete it in the same drain. + this->deliver_pending_search_(); + } + break; + } + case ESP_GATTC_DISCONNECT_EVT: { + if (!this->check_addr_(param->disconnect.remote_bda)) + return false; + this->handle_disconnect_evt_(param); + break; + } + case ESP_GATTC_CLOSE_EVT: { + if (this->conn_id_ != param->close.conn_id) + return false; + this->release_services(); + this->set_idle_(); + // The one connected=false report: the wrapper frees the slot on it, + // so it must not fire before the controller finished closing. + this->listener_->on_connection_state(false, 0, param->close.reason); + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->conn_id_ != param->search_cmpl.conn_id) + return false; + ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); + if (this->state() == ClientState::DISCONNECTING) { + // Teardown owns the link; the result is never delivered, skip the + // work. + break; + } + this->search_status_ = this->handle_search_cmpl_(static_cast(param->search_cmpl.status)); + this->search_state_ = + this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE; + this->set_state(ClientState::ESTABLISHED); + this->deliver_pending_search_(); + break; + } + case ESP_GATTC_READ_CHAR_EVT: + case ESP_GATTC_READ_DESCR_EVT: { + if (this->conn_id_ != param->read.conn_id) + return false; + bool ok = param->read.status == ESP_GATT_OK; + this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr, + ok ? param->read.value_len : 0, ok ? 0 : param->read.status); + break; + } + case ESP_GATTC_WRITE_CHAR_EVT: + case ESP_GATTC_WRITE_DESCR_EVT: { + if (this->conn_id_ != param->write.conn_id) + return false; + this->listener_->on_write_result(param->write.handle, + param->write.status == ESP_GATT_OK ? 0 : param->write.status); + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state(param->reg_for_notify.handle, true, + param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); + break; + } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state( + param->unreg_for_notify.handle, false, + param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (this->conn_id_ != param->notify.conn_id) + return false; + ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle); + this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); + break; + } + default: + break; + } + return true; +} + +void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + // Always accept; a refused response means no AUTH_CMPL, so answer the + // pairing request with the failure. + int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp", + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true)); + if (sec_err != 0) { + this->listener_->on_pairing_result(sec_err); + } + break; + } + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + this->listener_->on_pairing_result( + param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); + break; + } + default: + break; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..0d0b4fed5b --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,142 @@ +// Bluedroid (esp32) GATT client backend: the esp32 arm of the +// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection +// wrapper. Not a BLEClientBase: the tracker's promote loop owns +// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only +// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in +// the tracker-invoked connect() override. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::bluetooth_connection { + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +class BluetoothConnection; +#endif + +// One class carries both halves: the tracker's ESPBTClient surface (its +// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the +// virtual connect()/disconnect()) and the neutral contract ops. The +// contract's teardown op is named gatt_disconnect() because the tracker's +// void disconnect() cannot overload with an int-returning twin. +class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { + public: + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; + + // Lifecycle of one connection attempt's service search. + enum class SearchState : uint8_t { + NONE, // no search this attempt + PRESTARTED, // issued at OPEN_EVT, no claimant yet + PRESTART_DONE, // completed with search_status_ latched, no claimant yet + CLAIMED, // in flight with a claimant (pre-started or direct) + REPORT_PENDING // completed and claimed: deliver on the next flush + }; + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + // Wired by codegen before setup and invariant for the device lifetime. + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- esp32_ble_tracker::ESPBTClient ---- + bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void connect() override; + void disconnect() override; + bool wants_parsed_advertisements() override { return false; } + void on_scan_end() override {} + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + bool cancel_gatt_disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + // Contract stub: the proxy streams in place; the on-demand materializer + // for direct consumers lands with #18205. NOTE: a direct consumer reaching + // this stub gets an empty table indistinguishable from a service-less + // peer - do not ship one against this backend before the materializer. + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services(); + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// In-place service streamer (the proxy wrapper detects and prefers it): + /// builds one api response batch directly from Bluedroid's cached database, + /// so the streaming peak is the response itself - the old esp32 model. + void stream_service_batch(BluetoothConnection &conn); +#endif + + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + + protected: + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + int handle_search_cmpl_(esp_gatt_status_t status); + void deliver_pending_search_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + int check_and_log_error_(const char *operation, esp_err_t err); + void log_gattc_warning_(const char *operation, int code); + + // Group 1: pointers / composed objects + ble_device_base::GattClientListener *listener_{nullptr}; + // Group 2: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 3: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 4: 2-byte types + uint16_t conn_id_{UNSET_CONN_ID}; + uint16_t service_total_{0}; + + // Group 5: 1-byte types + esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes + // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. + uint8_t remote_addr_type_{0}; + esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE}; + uint8_t connection_index_{0}; + // Terminates an in-flight stream (never send a partial list as authoritative) + // and marks a cleaned cache unsafe to walk (Bluedroid asserts). + bool services_released_ : 1 {false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_ : 1 {false}; + // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. + bool mtu_failed_ : 1 {false}; + // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() + // completes from it. Reset by set_idle_(). + static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); + SearchState search_state_ : 4 {SearchState::NONE}; + // esp_gatt_status_t of the completed search, held until claimed. + uint8_t search_status_{0}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h new file mode 100644 index 0000000000..3c982d81ae --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -0,0 +1,67 @@ +// bluetooth_connection_gatt_backend.h +// +// Binds ble_device_base::BLEGattConnection to the build's one GATT backend. +// Backend and consumer both live in this component, so the ladder does too; +// backends implement ble_gatt_client.h (the neutral contract). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#if defined(USE_RP2040_BLE) +#include "bluetooth_connection_rp2.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_ESP32_BLE) +#include "bluetooth_connection_bluedroid.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient +#elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) +// Emitted only by the host unit-test manifest: the tests compile the hub +// wrapper standalone, so bind a do-nothing backend. Every other backend-less +// build hits the #error below. +namespace esphome::bluetooth_connection { + +class StubGattBackend { + public: + void set_listener(ble_device_base::GattClientListener *listener) {} + int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + bool cancel_gatt_disconnect() { return false; } + int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int read_descriptor(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + int notify_characteristic(uint16_t handle, bool enable) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int pair() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + ble_device_base::GattServiceTable get_service_table() { return {}; } + void set_connection_type(ble_device_base::ConnectionType ct) {} + void release_services() {} +}; + +} // namespace esphome::bluetooth_connection +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::StubGattBackend +#else +#error "USE_BLE_GATT_CLIENT is set but this build has no GATT backend; add an alias arm here" +#endif + +namespace esphome::ble_device_base { + +using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; +static_assert(BLEGattConnectionContract, + "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); +#undef ESPHOME_BLE_GATT_CONNECTION_TYPE + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp new file mode 100644 index 0000000000..ddab4812cc --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -0,0 +1,580 @@ +// The proxy's per-slot connection wrapper, shared by every platform. +// +// SERVICE STREAMING HAZARD - read before touching the streaming code here or +// in the platform streamers (bluetooth_connection_bluedroid.cpp). +// +// A V3 client caches the service list it receives as the device's complete, +// permanent database. Nothing on the wire marks a list as partial, so a +// stream that is truncated, has a skipped batch, or is terminated early +// would be cached whole and poison every later session with the device. +// +// The rule: it is always better to send nothing and let the client time out +// than to let services-done follow an incomplete stream. Concretely: +// - a refused batch rewinds the cursor and is retried, never skipped; +// - services-done is sent only after every batch was accepted; +// - every interruption (subscriber lost or swapped, backend abort, +// bounds-check failure) parks or aborts WITHOUT services-done and drops +// any owed done; +// - a new GetServices supersedes an owed done, so a stale done can never +// land on a fresh request's empty accumulator and cache it as empty. +// The client only caches a list terminated by services-done within the same +// request; timeouts, disconnects and errors raise instead of caching. +#include "bluetooth_connection_hub.h" + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +void BluetoothConnection::set_address(uint64_t address) { + // Keep the proxy's pre-allocated connections-free message in step + this->proxy_->update_address_slot_(this->address_, address); + // Slot changing hands: anything owed belonged to the old address. The + // choke point for every reassignment, not just reset_connection_()'s path. + this->clear_owed_flags_(); + this->address_ = address; + if (address == 0) { + this->address_str_[0] = '\0'; + return; + } + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + format_mac_addr_upper(mac, this->address_str_); +} + +void BluetoothConnection::initiate_connection(uint8_t address_type) { + // No connect timeout here: the API client's own timeout or the api-gone + // sweep drives disconnect(). + this->state_ = ClientState::CONNECTING; + int err = this->backend_->connect(this->address_, address_type); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + } +} + +void BluetoothConnection::disconnect() { + // Idempotent: the proxy's teardown loop calls this every 100 ms while the + // API subscriber is gone, and a repeat call reaching the backend would + // re-arm its teardown timer so the safety timeout never fires. + if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { + return; + } + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nonzero means nothing to tear down (both backends): free the slot. + // Accepted teardowns always reach a terminal report. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err); + this->reset_connection_(err); + return; + } + this->state_ = ClientState::DISCONNECTING; +} + +void BluetoothConnection::on_pairing_result(int status) { + if (this->address_ == 0) { + // A drop before completion already answered: reset_connection_slot_ sends + // the connection response, which the client's pair watcher raises on. + return; + } + this->paired_ = status == 0; + this->proxy_->send_device_pairing(this->address_, status == 0, status); +} + +void BluetoothConnection::reset_connection_(conn_err_t reason) { + if (this->pending_error_ != 0) { + reason = this->pending_error_; + this->pending_error_ = 0; + } + this->state_ = ClientState::IDLE; + this->services_discovered_ = false; + this->paired_ = false; + // Link gone: the slot may hold a different device before the drain runs. + this->clear_owed_flags_(); + this->backend_->release_services(); + this->proxy_->reset_connection_slot_(this, reason); +} + +// ---- backend event listener ---- + +void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { + if (connected && this->address_ == 0) { + // Late completion for a slot that was already freed: nothing to report, + // and the api-gone sweep or a new reservation owns the slot now. + // Return ignored: nonzero just means the backend was already idle, and + // re-arming a freed slot could clobber a new reservation. + this->backend_->gatt_disconnect(); + return; + } + if (connected && this->state_ == ClientState::DISCONNECTING) { + // The link came up after a disconnect request won the race; finish the + // teardown instead of reporting a connection the client no longer wants. + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nothing left to tear down after all. + this->reset_connection_(err); + } + return; + } + if (connected) { + this->mtu_ = mtu; + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + // The API client has the services cached; never discover them. No + // discovery phase needs the fast interval, so settle straight into the + // shared steady-state parameters. Both backends already open cached + // connections with these values (esp32 prefer-params, rp2 initiating + // params), so this request is normally redundant - kept as a backstop + // in case the initial parameters were negotiated away. + this->state_ = ClientState::ESTABLISHED; + // The one D-level line for a cached connect; the uncached path narrates + // through "Discovery finished" instead. + ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_, + this->address_str_, mtu); + int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, + ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, + ble_device_base::MEDIUM_CONN_TIMEOUT); + if (param_err != 0) { + // Survivable: the link just stays on the fast interval. + ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, + param_err); + } + this->send_connected_reply_(); + this->proxy_->send_connections_free(); + return; + } + // V3_WITHOUT_CACHE: discover services first — the connected response is + // sent when discovery completes (MTU + services before the response). + this->state_ = ClientState::CONNECTED; + int err = this->backend_->discover_services(); + if (err != 0) { + ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); + // Latch the real cause for the disconnect report. + this->latch_pending_error_(err); + this->disconnect(); + } + return; + } + // Disconnected, connect failed, or teardown complete + if (this->address_ == 0) { + return; // Slot already freed + } + ESP_LOGD(TAG, "[%d] [%s] Disconnected, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, + error); + this->reset_connection_(error); +} + +void BluetoothConnection::on_service_discovery_done(int error) { + if (error != 0) { + ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); + // Carry the GATT error into the disconnection report so the client sees + // the real cause instead of a generic HCI reason. + this->latch_pending_error_(error); + this->disconnect(); + return; + } + ESP_LOGD(TAG, "[%d] [%s] Discovery finished, sending connected (mtu=%u)", this->connection_index_, this->address_str_, + this->mtu_); + this->state_ = ClientState::ESTABLISHED; + this->services_discovered_ = true; + this->send_connected_reply_(); + this->proxy_->send_connections_free(); +} + +void BluetoothConnection::flush_owed_replies_() { + // Connected first: the client should never see services-done or an ack for + // a link it has not been told is up. Structural, not size-dependent: a + // still-owed connected reply defers the smaller sends to the next tick. + if (this->connected_reply_owed_) { + this->send_connected_reply_(); + if (this->connected_reply_owed_) { + // The retry limits are wall-clock windows: age the deferred budgets so + // a reply cannot outlive the window it was sized for. + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->age_services_done_(); + } + if (this->has_pending_ack_()) { + this->age_pending_ack_(); + } + return; + } + } + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_services_done_(); + } + if (this->has_pending_ack_()) { + this->flush_pending_ack_(); + } +} + +void BluetoothConnection::send_connected_reply_() { + if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) { + this->connected_reply_owed_ = false; + return; + } + // Warn on the leading edge only, as elsewhere: the drop must be visible but + // must not add traffic to the connection that just refused a frame. + if (!this->connected_reply_owed_) { + ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_); + this->connected_reply_owed_ = true; + } +} + +void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, + operation, handle, status); +} + +void BluetoothConnection::note_batch_stalled_() { + if (this->batch_stalled_) + return; + this->batch_stalled_ = true; + ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_, + this->address_str_); +} + +/// Both payload-free acks are just (address, handle); only the type differs. +template +static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) { + Response resp; + resp.address = address; + resp.handle = handle; + return api_connection->send_message(resp); +} + +/// Sole construction site, so a re-offer cannot drift from the original. +bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (kind == PendingAck::PENDING_ACK_ERROR) { + // Proxy owns the error reply and reports a refusal the same way. + return this->proxy_->send_gatt_error(this->address_, handle, error); + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return true; // Nobody subscribed: nothing is owed + switch (kind) { + case PendingAck::PENDING_ACK_WRITE: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NOTIFY: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NONE: + case PendingAck::PENDING_ACK_ERROR: // returned above + return true; + } + // No default label above, so a new enumerator is a -Wswitch warning rather + // than a silent notify reply. This return only satisfies -Wreturn-type. + return true; +} + +void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (this->try_send_ack_(kind, handle, error)) + return; + // Report a newly owed reply and a displaced one; displacing is the case + // that loses a reply. Re-refusing the same one stays quiet, and so does a + // fresh deferral for the handle already warned about: a congested bulk + // transfer re-asks the same handle every cycle and each ack would warn. + if (!this->has_pending_ack_()) { + if (!this->ack_deferred_warned_ || this->pending_ack_handle_ != handle) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_, + this->address_str_, handle); + this->ack_deferred_warned_ = true; + } + } else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_, + this->address_str_, this->pending_ack_handle_, handle); + } + this->latch_pending_ack_(kind, handle, error); +} + +void BluetoothConnection::flush_pending_ack_() { + if (!this->has_pending_ack_()) + return; + if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { + this->clear_pending_ack_(); + return; + } + this->age_pending_ack_(); +} + +void BluetoothConnection::age_pending_ack_() { + if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { + // Undeliverable: past here the client has given up and may have re-asked, + // and a late reply would answer the new request instead of this one. + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_, + this->address_str_, this->pending_ack_handle_); + this->clear_pending_ack_(); + } +} + +void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { + // Late completion for a freed slot; nothing to report. + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("reading char/descriptor", handle, error); + this->send_gatt_error_(handle, error); + return; + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTReadResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + // Not latched: would mean holding the payload through the congestion + // that refused it. The client's read timeout arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); + } +} + +void BluetoothConnection::on_write_result(uint16_t handle, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_("writing char/descriptor", handle, error); + this->send_gatt_error_(handle, error); + return; + } + this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle); +} + +void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { + if (this->address_ == 0) + return; + if (error != 0) { + this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, + error); + this->send_gatt_error_(handle, error); + return; + } + this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle); +} + +void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->address_ == 0) + return; + ESP_LOGV(TAG, "[%d] [%s] Notify: handle=0x%2X", this->connection_index_, this->address_str_, handle); + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return; + api::BluetoothGATTNotifyDataResponse resp; + resp.address = this->address_; + resp.handle = handle; + resp.set_data(data, len); + if (!api_connection->send_message(resp)) { + // Not latched, same reason as the read reply. Notify data is lossy: the + // peripheral will not resend it. Warn on the first drop only; a congested + // link drops a whole stream and one line per notify floods the log. + if (!this->notify_drop_warned_) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response, handle 0x%04X", this->connection_index_, + this->address_str_, handle); + this->notify_drop_warned_ = true; + } else { + ESP_LOGV(TAG, "[%d] [%s] Failed to send notify data response, handle 0x%04X", this->connection_index_, + this->address_str_, handle); + } + } +} + +// ---- GATT operations ---- + +conn_err_t BluetoothConnection::check_connected_op_(const char *action, const char *type) const { + if (this->connected()) { + return CONN_OK; + } + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str_, action, + type); + return GATT_NOT_CONNECTED; +} + +conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); + if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_characteristic(handle); +} + +conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, + bool response) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); + if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_characteristic(handle, data, static_cast(length), response); +} + +conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); + if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->read_descriptor(handle); +} + +// The neutral backend contract performs descriptor writes acknowledged, so +// the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). +conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, + bool /*response*/) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); + if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); + return this->backend_->write_descriptor(handle, data, static_cast(length)); +} + +conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY); + if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) + return err; + ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, + enable ? "Registering for" : "Unregistering for", handle); + return this->backend_->notify_characteristic(handle, enable); +} + +conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (conn_err_t err = this->check_connected_op_("update params of", "connection"); err != CONN_OK) + return err; + return this->backend_->update_connection_params(min_interval, max_interval, latency, timeout); +} + +// ---- Service streaming ---- + +void BluetoothConnection::send_services_done_() { + if (this->proxy_->send_gatt_services_done(this->address_)) { + // Sent, or subscriber gone (park silently; its timeout arbitrates). + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (this->send_service_ != SERVICES_DONE_PENDING) { + // Warn on the transition only; retries stay silent. + ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); + this->services_done_retries_ = 0; + this->send_service_ = SERVICES_DONE_PENDING; + } else { + this->age_services_done_(); + } +} + +void BluetoothConnection::age_services_done_() { + if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + } +} + +void BluetoothConnection::send_service_for_discovery_() { + auto table = this->backend_->get_service_table(); + if (this->send_service_ >= table.service_count) { + this->backend_->release_services(); + this->send_services_done_(); + return; + } + + // The subscriber vanished mid-stream; the api-gone sweep tears the + // connection down anyway. + auto *api_conn = this->proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, + this->address_str_); + this->park_service_stream_(); + return; + } + + // Check if client supports efficient UUIDs + bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); + + // Prepare response + api::BluetoothGATTGetServicesResponse resp; + resp.address = this->address_; + + // Dynamic batching based on actual size, same contract as the esp32 streamer + size_t current_size = resp.calculate_size(); + int16_t batch_start = this->send_service_; + + while (this->send_service_ < table.service_count) { + const auto &service = table.services[this->send_service_]; + + // If this service likely won't fit, send current batch (unless it's the first) + size_t estimated_size = estimate_service_size(service.characteristic_count, use_efficient_uuids); + if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service.uuid, use_efficient_uuids); + service_resp.handle = service.start_handle; + + // Bounds-check the backend's index ranges against the table totals rather + // than trusting its discovery bookkeeping blindly. A miscounted non-empty + // range must not stream a truncated database as authoritative (V3 clients + // cache it permanently): abort and tear the connection down; the client + // times out and retries. Empty ranges are tolerated regardless of index. + uint16_t char_count = service.characteristic_count; + if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { + ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); + return; + } + if (char_count > 0) { + service_resp.characteristics.init(char_count); + for (uint16_t ci = 0; ci < char_count; ci++) { + const auto &chr = table.characteristics[service.first_characteristic + ci]; + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, chr.uuid, use_efficient_uuids); + characteristic_resp.handle = chr.value_handle; + characteristic_resp.properties = chr.properties; + uint16_t desc_count = chr.descriptor_count; + if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { + ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", + this->connection_index_, this->address_str_, this->send_service_); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); + return; + } + if (desc_count == 0) { + continue; + } + characteristic_resp.descriptors.init(desc_count); + for (uint16_t di = 0; di < desc_count; di++) { + const auto &desc = table.descriptors[chr.first_descriptor + di]; + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc.uuid, use_efficient_uuids); + descriptor_resp.handle = desc.handle; + } + } + } + + if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // Send the message with dynamically batched services; on a failed send, + // rewind the cursor so the batch is retried instead of silently skipped + // (bounded: a subscriber that stays gone ends streaming via the api-lost + // rewind above). + if (!api_conn->send_message(resp)) { + this->note_batch_stalled_(); + this->send_service_ = batch_start; + return; + } + this->batch_stalled_ = false; +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h new file mode 100644 index 0000000000..47181e81a7 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -0,0 +1,281 @@ +// BluetoothConnection: drives the build's GATT backend (the +// ble_device_base::BLEGattConnection alias) and translates its events into +// the proxy's API messages. One wrapper for every platform; per-backend +// differences live behind the alias and the streamer cut-through. + +#pragma once + +#include "bluetooth_connection.h" + +// The wrapper exists to serve the proxy's API surface; direct consumers +// drive the backend themselves, so backend-only builds compile this header +// empty. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "bluetooth_connection_gatt_backend.h" +#include "esphome/core/helpers.h" + +namespace esphome::bluetooth_proxy { +class BluetoothProxy; +} // namespace esphome::bluetooth_proxy + +namespace esphome::bluetooth_connection { + +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; + +/// A refused GATT reply owed to the current subscriber. Payload-free only: +/// these rebuild from address + handle + error, so a retry costs no buffered +/// data. Read and notify-data carry payloads and are deliberately absent. +enum class PendingAck : uint8_t { + PENDING_ACK_NONE = 0, + PENDING_ACK_WRITE, + PENDING_ACK_NOTIFY, + PENDING_ACK_ERROR, +}; + +class BluetoothConnection final : public ble_device_base::GattClientListener { + public: + /// Wire the platform backend. Called from codegen before setup. + void set_backend(ble_device_base::BLEGattConnection *backend) { + this->backend_ = backend; + backend->set_listener(this); + } + + // ---- proxy dispatch surface ---- + conn_err_t read_characteristic(uint16_t handle); + conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t read_descriptor(uint16_t handle); + conn_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); + conn_err_t notify_characteristic(uint16_t handle, bool enable); + conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + + /// Streamer abort: latch the GATT cause, park the cursor, tear down. + void abort_service_stream(conn_err_t err) { + this->latch_pending_error_(err); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); + } + + /// Start connecting with the API address type (BLE_ADDR_TYPE_* code + /// space). Failures report through the same reset path a failed open + /// takes. + void initiate_connection(uint8_t address_type); + void disconnect(); + /// A connect request racing a scheduled teardown: true when the backend + /// had not started closing - the in-flight open resumes and reports + /// connected. False once the teardown owns the link. + bool cancel_teardown() { + if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) { + this->state_ = ClientState::CONNECTING; + return true; + } + return false; + } + bool is_paired() const { return this->paired_; } + void set_unpaired() { this->paired_ = false; } + conn_err_t pair() { return this->backend_->pair(); } + + void set_address(uint64_t address); + uint64_t get_address() const { return this->address_; } + const char *address_str() const { return this->address_str_; } + uint8_t get_connection_index() const { return this->connection_index_; } + + ClientState state() const { return this->state_; } + void set_state(ClientState st) { this->state_ = st; } + bool connected() const { return this->state_ == ClientState::ESTABLISHED; } + void set_connection_type(ConnectionType ct) { + this->connection_type_ = ct; + // Both backends branch on the type before connecting (bluedroid picks + // prefer-params and the with-cache report at OPEN_EVT; rp2 picks the + // initiating parameters), so this must be set before the connect starts. + this->backend_->set_connection_type(ct); + } + // Latched at discovery completion rather than read from the backend table: + // streaming frees the table, and this must stay true for the connection's + // lifetime (a repeat GetServices is silently ignored, never answered with + // an authoritative empty database). + bool has_gatt_services() const { return this->services_discovered_; } + + /// Stream any pending service-discovery batch (proxy loop; the backend + /// owns the disconnect safety timer). + void process_pending_services() { + if (this->send_service_ >= 0) { + this->stream_pending_(this->backend_); + } + } + + // ---- backend event listener (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; + + protected: + friend class bluetooth_proxy::BluetoothProxy; + // The Bluedroid backend streams services in place from its stack cache. + friend class BluedroidGattClient; + + /// First cause wins: a later, less specific error must not overwrite it. + void latch_pending_error_(conn_err_t err) { + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + + /// Latch a refused reply for the proxy drain. One slot per connection, + /// newest wins: a GATT client works one request at a time, and a discarded + /// reply falls back to the timeout it would have hit anyway. + void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) { + this->pending_ack_retries_ = 0; + this->pending_ack_ = kind; + this->pending_ack_handle_ = handle; + this->pending_ack_error_ = error; + } + void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; } + /// Drop an owed reply this re-ask makes stale. Clients match futures on + /// response type as well as handle, so an owed error (which resolves any op + /// on the handle) is cleared by any re-ask, other kinds only by their own. + void supersede_pending_ack_(uint16_t handle, PendingAck kind) { + if (this->has_pending_ack_() && this->pending_ack_handle_ == handle && + (this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) { + this->clear_pending_ack_(); + } + } + bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; } + /// Warn on the stall's leading edge only. The batch is never lost (the + /// caller rewinds the cursor), and a warning per attempt would add traffic + /// to the connection already refusing frames. Both streamers route here. + void note_batch_stalled_(); + /// Send the connected=true reply, latching it if the API refuses. Rebuilt + /// from address_ and mtu_, so the latch is one bit; a dropped confirmation + /// leaves the client timing out while this slot holds a live link. No retry + /// bound: the slot's lifetime is the bound (teardown clears the flag). + void send_connected_reply_(); + /// Re-offer everything this slot owes. One entry point so the proxy drain + /// does not have to know which latches exist. + void flush_owed_replies_(); + /// Drop everything this slot owes, in one write to the shared tail byte. + void clear_owed_flags_() { + this->pending_ack_ = PendingAck::PENDING_ACK_NONE; + this->batch_stalled_ = false; + this->connected_reply_owed_ = false; + this->ack_deferred_warned_ = false; + this->notify_drop_warned_ = false; + } + /// Sole construction site for these replies, shared by send and retry. + bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); + /// First attempt: send, and latch it for the drain if the API refuses. + void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0); + /// Report a rejected request. Latched like a completion reply, so a + /// refused frame does not strand the client for its whole timeout. + void send_gatt_error_(uint16_t handle, conn_err_t error) { + this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error); + } + /// Re-offer the owed reply; clears on success, stays owed on a refusal. + void flush_pending_ack_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_pending_ack_(); + // A backend providing its own streamer (see the contract doc) builds the + // response in place from its stack cache; the rest use the table streamer. + // Template so the discarded branch is not odr-checked against backends + // that lack the method. + template void stream_pending_(Backend *backend) { + if constexpr (requires { backend->stream_service_batch(*this); }) { + backend->stream_service_batch(*this); + } else { + this->send_service_for_discovery_(); + } + } + /// Park the stream without services-done and free any held table: an + /// interrupted stream must never be declared complete (the client's + /// timeout arbitrates), and an owed done is dropped with it. + void park_service_stream_() { + this->batch_stalled_ = false; + if (this->send_service_ >= 0) { + this->backend_->release_services(); + this->send_service_ = DONE_SENDING_SERVICES; + } else if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_service_ = DONE_SENDING_SERVICES; + } + } + void send_service_for_discovery_(); + /// Send services-done and settle the cursor: DONE when it lands (or no + /// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain + /// retries). Callers release the table first; the message needs only the + /// address. + void send_services_done_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_services_done_(); + void reset_connection_(conn_err_t reason); + conn_err_t check_connected_op_(const char *action, const char *type) const; + void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); + + // Memory optimized layout for 32-bit systems + // Group 1: Pointers (4 bytes each, naturally aligned) + bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; + ble_device_base::BLEGattConnection *backend_{nullptr}; + + // Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays + // 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8). + int16_t send_service_{INIT_SENDING_SERVICES}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; + + // Group 3: 8-byte and 4-byte types + uint64_t address_{0}; + conn_err_t pending_error_{0}; + // Full width: the GATT error domain is open-ended (ble_gatt_client.h) and + // forwarded untranslated, so narrowing would corrupt platform codes. + conn_err_t pending_ack_error_{0}; + + // Group 4: Arrays + char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + // Parked here rather than in Group 2: address_str_ ends 2-aligned, so this + // uses tail slack instead of pushing address_ out by 6 bytes of padding. + uint16_t pending_ack_handle_{0}; + + // Group 5: bit-packed tail. The first two bytes were already full, so the + // first added bit forced a third and took the 8-aligned object 48 -> 56; + // the handle, error and retry counter ride in that padding. Two bitfield + // bits left; another byte-sized member costs 8 per slot. + static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); + static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), + "connection_type_ bitfield too narrow"); + // Ordered so neither byte's fields straddle a storage unit: 3+5 and + // 4+2+1+1 fill the first two tail bytes exactly. + ClientState state_ : 3 {ClientState::IDLE}; + static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); + uint8_t services_done_retries_ : 5 {0}; + uint8_t connection_index_ : 4 {0}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + bool paired_ : 1 {false}; + bool services_discovered_ : 1 {false}; + static_assert(static_cast(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow"); + PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; + /// Set while a refused batch is retrying, so only the first one warns. + bool batch_stalled_ : 1 {false}; + /// An owed connected=true reply; the proxy's paced drain re-offers it. + bool connected_reply_owed_ : 1 {false}; + /// Set once the deferred warn fired; with an unchanged pending_ack_handle_ + /// it keeps re-deferrals of the same handle quiet (see send_ack_). + bool ack_deferred_warned_ : 1 {false}; + /// Set on the first dropped notify; later drops log at verbose only. + bool notify_drop_warned_ : 1 {false}; + // Plain byte after the bitfields: takes the padding byte instead of + // straddling pending_ack_'s storage unit and growing the object. + static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); + uint8_t pending_ack_retries_{0}; +}; + +// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad +// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit. +static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, + "BluetoothConnection layout regressed on a 32-bit target"); + +} // namespace esphome::bluetooth_connection + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp new file mode 100644 index 0000000000..16a89dcfdd --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -0,0 +1,1287 @@ +#include "bluetooth_connection_rp2.h" + +#include "bluetooth_connection.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection"; + +using ble_device_base::ESPBTUUID; +using ble_device_base::GATT_ERR_NOT_CONNECTED; +using ble_device_base::GATT_ERR_NO_MEMORY; + +// Engine-owned timeouts: BTstack has a 30 s ATT transaction timeout but no +// connect timeout — a stuck LE_CONNECTING both blocks future gap_connect calls +// and keeps the scan inhibited, so the engine cancels after 20 s. The +// disconnect timeout mirrors the esp32 CLOSE_EVT safety net. +static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +// Budget after a cancel is in flight: its completion normally lands within +// tens of ms, and while the engine waits it pins the stack-wide connect slot, +// so a lost completion must cost seconds, not another full connect budget. +static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000; +// Pending engines re-attempt gap_connect on this cadence instead of every +// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock. +static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50; +// Can-send windows normally open within a connection interval (tens of ms). +static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; + +// HCI "connection timeout" reason, reported when a teardown had to be forced. +static constexpr uint8_t HCI_REASON_CONNECTION_TIMEOUT = 0x08; + +// Initiating-scan parameters and connection-event lengths for outgoing +// connections (BTstack-specific knobs; the connection intervals themselves are +// the shared FAST/MEDIUM parameters from ble_device_base/ble_client_state.h, +// used in the same lifecycle places as esp32: FAST for connect and service +// discovery, MEDIUM once established). +static constexpr uint16_t CONN_SCAN_INTERVAL = 96; // 60 ms in 0.625 ms units +static constexpr uint16_t CONN_SCAN_WINDOW = 48; // 30 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MIN = 16; // 10 ms in 0.625 ms units +static constexpr uint16_t CONN_CE_MAX = 48; // 30 ms in 0.625 ms units + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; +uint8_t RP2GattClient::instance_count = 0; +btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; +btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; +RP2GattClient *RP2GattClient::connect_owner = nullptr; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { + if (uuid16 != 0) { + return ESPBTUUID::from_uint16(uuid16); + } + // BTstack structs carry the 128-bit form big-endian (printable order). + return ESPBTUUID::from_raw_reversed(uuid128); +} + +void RP2GattClient::setup() { + // Pre-create every pool entry so the packet handlers' allocate() calls are + // always a free-list pop -- the IRQ path must never reach malloc(). + if (!this->event_pool_.warm() || !this->notify_pool_.warm()) { + ESP_LOGE(TAG, "GATT event pool warm-up failed"); + this->mark_failed(); + return; + } + + // Register this engine for IRQ-context event routing. + if (instance_count >= ESPHOME_BLE_GATT_CLIENT_COUNT) { + // Cannot happen with codegen-sized storage; refuse loudly if it ever does. + ESP_LOGE(TAG, "GATT client registry full"); + this->mark_failed(); + return; + } + { + // One locked section: the slot store lands before the count bump, and a + // live HCI handler (N > 1 builds) cannot read a half-written registry. + BluetoothLock lock; + this->engine_index_ = instance_count; + instances[instance_count] = this; + instance_count++; + // One HCI event handler for all engine instances (BTstack supports + // multiple registrations, so rp2040_ble's own handler is unaffected). + if (hci_event_registration.callback == nullptr) { + hci_event_registration.callback = &RP2GattClient::hci_packet_handler; + hci_add_event_handler(&hci_event_registration); + sm_event_registration.callback = &RP2GattClient::sm_packet_handler; + sm_add_event_handler(&sm_event_registration); + } + } + +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + + this->disable_loop(); +} + +#ifdef USE_OTA_STATE_LISTENER +void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + // esp32 parity (its tracker disconnects every client at OTA start): free + // the shared radio for the transfer. No restore needed; the client + // reconnects, and on success the device reboots anyway. + if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) { + this->gatt_disconnect(); + } +} +#endif + +float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } + +void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } + +// ---- IRQ-context handlers: copy-and-enqueue only ---- + +RP2GattClient *RP2GattClient::instance_for_con_handle(hci_con_handle_t con_handle) { + for (uint8_t i = 0; i < instance_count; i++) { + if (instances[i]->con_handle_ == con_handle) { + return instances[i]; + } + } + return nullptr; +} + +void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + switch (event_type) { + case HCI_EVENT_META_GAP: { + if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { + break; + } + uint8_t status = gap_subevent_le_connection_complete_get_status(packet); + hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + // Route by ownership, not address: gap_connect refuses a new + // create-connection until the previous completion is processed, so the + // event belongs to the owner by construction. Cancel completions carry + // a zeroed peer address on this controller, so an address match would + // drop them and pin the owner until its backstop. + RP2GattClient *inst = connect_owner; + static constexpr bd_addr_t ZERO_ADDR = {}; + if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 && + memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) { + // Addressed completion for a peer the owner is not connecting to: a + // success delayed past a cancel and an ownership handoff (the cancel + // idles the stack's request immediately) must not stamp the old + // procedure's link onto the new owner. Zero-address (cancel) + // completions need no such guard: BTstack only emits them while its + // request state is idle, and a new owner re-arms that state when it + // claims the token, so a stale cancel completion is swallowed by the + // stack, never re-attributed. A successful stale link still needs + // disposal (same hazard as the unowned branch below). + if (status == 0) { + gap_disconnect(con_handle); + } + break; + } + connect_owner = nullptr; + if (inst == nullptr) { + if (status == 0) { + // Nobody owns this late link (the owner escalated first): tear it + // down here or the hci_connection_t leaks and the peer answers + // DISALLOWED until reboot. + gap_disconnect(con_handle); + } + break; + } + if (status == 0) { + // Stamp the handle here in the BTstack context: a disconnection + // racing the queued CONNECTED event arrives in this same context + // and must route by handle (it carries no address). + inst->con_handle_ = con_handle; + } + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); + break; + } + case HCI_EVENT_DISCONNECTION_COMPLETE: { + // Routable even against a still-queued CONNECTED event: the handle is + // stamped in this context at connection-complete time. + RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + switch (hci_event_packet_get_type(packet)) { + case SM_EVENT_JUST_WORKS_REQUEST: + // Confirming from the SM callback is the intended BTstack pattern. + // Unscoped on purpose: no peripheral role exists in-tree, and scoping + // would drop a request racing the queued CONNECTED event. + sm_just_works_confirm(sm_event_just_works_request_get_handle(packet)); + break; + case SM_EVENT_PAIRING_COMPLETE: { + RP2GattClient *inst = instance_for_con_handle(sm_event_pairing_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_pairing_complete_get_status(packet), 0); + } + break; + } + case SM_EVENT_REENCRYPTION_COMPLETE: { + // A bonded peer re-encrypts instead of pairing; BTstack emits only this + // event on that path, so it answers the PAIR request too. + RP2GattClient *inst = instance_for_con_handle(sm_event_reencryption_complete_get_handle(packet)); + if (inst != nullptr) { + inst->enqueue_event_irq_(RP2GattEvent::PAIRING_RESULT, sm_event_reencryption_complete_get_status(packet), 0); + } + break; + } + default: + break; + } +} + +void RP2GattClient::gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size) { + if (type != HCI_EVENT_PACKET) { + return; + } + uint8_t event_type = hci_event_packet_get_type(packet); + // Every GATT event carries the connection handle in the same position via + // its accessor; route on it. + hci_con_handle_t con_handle; + switch (event_type) { + case GATT_EVENT_MTU: + con_handle = gatt_event_mtu_get_handle(packet); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: + con_handle = gatt_event_service_query_result_get_handle(packet); + break; + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: + con_handle = gatt_event_characteristic_query_result_get_handle(packet); + break; + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: + con_handle = gatt_event_all_characteristic_descriptors_query_result_get_handle(packet); + break; + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_value_query_result_get_handle(packet); + break; + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + con_handle = gatt_event_long_characteristic_descriptor_query_result_get_handle(packet); + break; + case GATT_EVENT_NOTIFICATION: + con_handle = gatt_event_notification_get_handle(packet); + break; + case GATT_EVENT_INDICATION: + con_handle = gatt_event_indication_get_handle(packet); + break; + case GATT_EVENT_QUERY_COMPLETE: + con_handle = gatt_event_query_complete_get_handle(packet); + break; + default: + return; + } + RP2GattClient *inst = instance_for_con_handle(con_handle); + if (inst != nullptr) { + inst->handle_gatt_event_irq_(event_type, packet); + } +} + +void RP2GattClient::handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet) { + switch (event_type) { + case GATT_EVENT_MTU: + this->enqueue_event_irq_(RP2GattEvent::MTU_EXCHANGED, 0, gatt_event_mtu_get_MTU(packet)); + break; + case GATT_EVENT_QUERY_COMPLETE: + this->enqueue_event_irq_(RP2GattEvent::QUERY_COMPLETE, gatt_event_query_complete_get_att_status(packet), 0); + break; + case GATT_EVENT_SERVICE_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->service_count_ >= RP2_GATT_MAX_SERVICES) { + this->truncated_ = true; + break; + } + gatt_client_service_t service; + gatt_event_service_query_result_get_service(packet, &service); + auto &dst = this->arena_->services[this->service_count_]; + dst.uuid = uuid_from_btstack(service.uuid16, service.uuid128); + dst.start_handle = service.start_group_handle; + dst.end_handle = service.end_group_handle; + dst.first_characteristic = 0; + dst.characteristic_count = 0; + this->service_count_++; + break; + } + case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->char_count_ >= RP2_GATT_MAX_CHARACTERISTICS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_t characteristic; + gatt_event_characteristic_query_result_get_characteristic(packet, &characteristic); + auto &dst = this->arena_->characteristics[this->char_count_]; + dst.uuid = uuid_from_btstack(characteristic.uuid16, characteristic.uuid128); + dst.value_handle = characteristic.value_handle; + dst.end_handle = characteristic.end_handle; + dst.properties = static_cast(characteristic.properties); + dst.first_descriptor = 0; + dst.descriptor_count = 0; + this->char_count_++; + break; + } + case GATT_EVENT_ALL_CHARACTERISTIC_DESCRIPTORS_QUERY_RESULT: { + if (this->arena_ == nullptr) { + break; + } + if (this->desc_count_ >= RP2_GATT_MAX_DESCRIPTORS) { + this->truncated_ = true; + break; + } + gatt_client_characteristic_descriptor_t descriptor; + gatt_event_all_characteristic_descriptors_query_result_get_characteristic_descriptor(packet, &descriptor); + auto &dst = this->arena_->descriptors[this->desc_count_]; + dst.uuid = uuid_from_btstack(descriptor.uuid16, descriptor.uuid128); + dst.handle = descriptor.handle; + this->desc_count_++; + break; + } + case GATT_EVENT_LONG_CHARACTERISTIC_VALUE_QUERY_RESULT: + // One blob per event at the reported offset; assemble into the op buffer. + this->assemble_blob_irq_(gatt_event_long_characteristic_value_query_result_get_value_offset(packet), + gatt_event_long_characteristic_value_query_result_get_value(packet), + gatt_event_long_characteristic_value_query_result_get_value_length(packet)); + break; + case GATT_EVENT_LONG_CHARACTERISTIC_DESCRIPTOR_QUERY_RESULT: + this->assemble_blob_irq_(gatt_event_long_characteristic_descriptor_query_result_get_descriptor_offset(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor(packet), + gatt_event_long_characteristic_descriptor_query_result_get_descriptor_length(packet)); + break; + case GATT_EVENT_NOTIFICATION: + this->enqueue_notify_irq_(gatt_event_notification_get_value_handle(packet), + gatt_event_notification_get_value(packet), + gatt_event_notification_get_value_length(packet)); + break; + case GATT_EVENT_INDICATION: + // BTstack auto-confirms indications; deliver like a notification. + this->enqueue_notify_irq_(gatt_event_indication_get_value_handle(packet), gatt_event_indication_get_value(packet), + gatt_event_indication_get_value_length(packet)); + break; + default: + break; + } +} + +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void RP2GattClient::assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len) { + if (offset >= RP2_GATT_MAX_ATTR_LEN) { + return; + } + if (len > RP2_GATT_MAX_ATTR_LEN - offset) { + len = RP2_GATT_MAX_ATTR_LEN - offset; + } + memcpy(this->op_buffer_ + offset, data, len); + if (offset + len > this->op_len_) { + this->op_len_ = offset + len; + } +} + +void RP2GattClient::enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value) { + RP2GattEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + this->event_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); + return; + } + event->type = type; + event->status = status; + event->value = value; + this->event_queue_.push(event); + this->enable_loop_soon_any_context(); +} + +void RP2GattClient::enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len) { + RP2GattNotifyEvent *event = this->notify_pool_.allocate(); + if (event == nullptr) { + this->notify_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); + return; + } + event->handle = handle; + event->len = len > RP2_GATT_MAX_ATTR_LEN ? RP2_GATT_MAX_ATTR_LEN : len; + memcpy(event->data, data, event->len); + this->notify_queue_.push(event); + this->enable_loop_soon_any_context(); +} +// NOLINTEND(clang-analyzer-unix.Malloc) + +// ---- Main-loop state machine ---- + +void RP2GattClient::loop() { + RP2GattEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + RP2GattEvent copy = *event; + this->event_pool_.release(event); + this->handle_event_(copy); + } + + RP2GattNotifyEvent *notify; + while ((notify = this->notify_queue_.pop()) != nullptr) { + if (this->notify_subscribed_(notify->handle)) { + this->listener_->on_notify_data(notify->handle, notify->data, notify->len); + } + this->notify_pool_.release(notify); + } + + uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); + if (dropped > 0) { + // Control events must not be lost; the connection state is no longer + // trustworthy — recover with a forced teardown. + ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped); + this->gatt_disconnect(); + } + uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); + if (notify_dropped > 0) { + ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped); + } + + if (this->state_ == EngineState::CONNECT_PENDING) { + uint32_t now = millis(); + if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { + // Never reached the radio; nothing stack-side to cancel. + ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_); + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) { + this->connect_retry_ms_ = now; + if (int err = this->try_gap_connect_(); err != 0) { + this->fail_connection_(static_cast(err)); + } + } + } else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID && + this->connect_cancel_attempted_; + uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS; + if (now - this->connect_started_ > budget) { + ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_); + bool link_up = this->state_ != EngineState::CONNECTING; + bool cancel_sent = false; + if (!link_up) { + BluetoothLock lock; + // Handle check under the lock: a success completion can stamp it in + // the BTstack context right up to this point, and escalating past a + // live link would orphan it (the queued CONNECTED event is dropped + // by the state guard once fail_connection_ runs). + link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID; + if (!link_up && connect_owner == this) { + // gap_connect_cancel is stack-global; only the engine whose + // create-connection is in flight may issue it. First timeout: + // cancel and give the completion a grace period. Second: the + // completion was lost, re-issue the cancel in case the procedure + // still runs (a no-op on an idle stack), then escalate. + gap_connect_cancel(); + cancel_sent = !this->connect_cancel_attempted_; + } + this->connect_cancel_attempted_ = true; + } + if (link_up) { + // The link is up (stamped mid-timeout or MTU exchange stalled): tear + // it down properly so the controller frees its side; the + // DISCONNECTING safety net below reclaims state if the disconnection + // event is lost. Dropping engine state without gap_disconnect would + // leak the live link and this engine's GATT slot for the rest of the + // boot. + this->gatt_disconnect(); + } else if (cancel_sent) { + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer so + // a lost event escalates on the short cancel budget. + this->connect_started_ = now; + } else { + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } + } + } else if (this->state_ == EngineState::DISCONNECTING) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_); + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && + millis() - this->write_no_rsp_started_ > WRITE_NO_RSP_TIMEOUT_MS) { + // The can-send window never opened; report instead of hanging the op slot. + bool timed_out = false; + { + BluetoothLock lock; + // The trampoline may have just sent it; its queued result wins. + if (this->event_queue_.empty()) { + this->op_type_ = OpType::NONE; + timed_out = true; + } + } + if (timed_out) { + ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_); + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); + } + } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && + this->event_queue_.empty() && this->notify_queue_.empty())) { + // Nothing pending: the enqueue path re-arms the loop from any context. + this->disable_loop(); + } +} + +void RP2GattClient::handle_event_(const RP2GattEvent &event) { + switch (event.type) { + case RP2GattEvent::CONNECTED: + this->handle_connected_(event.status, event.value); + break; + case RP2GattEvent::DISCONNECTED: + this->handle_disconnected_(event.status); + break; + case RP2GattEvent::MTU_EXCHANGED: + if (this->state_ == EngineState::MTU_EXCHANGE) { + this->mtu_ = event.value; + ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); + this->state_ = EngineState::READY; + // Scanning resumes and runs alongside the established connection. + this->release_scan_inhibit_(); + this->listener_->on_connection_state(true, this->mtu_, 0); + } + break; + case RP2GattEvent::QUERY_COMPLETE: + this->handle_query_complete_(event.status); + break; + case RP2GattEvent::WRITE_NO_RSP_DONE: + this->finish_write_no_rsp_(event.status); + break; + case RP2GattEvent::PAIRING_RESULT: + this->listener_->on_pairing_result(event.status); + break; + } +} + +void RP2GattClient::can_write_no_rsp_trampoline(void *context) { + // BTstack context: this callback IS the can-send window, so the deferred + // write happens here; only the result is enqueued for the main loop. + auto *self = static_cast(context); + if (self->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + uint8_t status = gatt_client_write_value_of_characteristic_without_response(self->con_handle_, self->op_handle_, + self->op_len_, self->op_buffer_); + if ((status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) && + gatt_client_request_to_write_without_response(&self->can_write_registration_, self->con_handle_) == 0) { + return; // next window retries; a failed re-arm falls through as an error + } + self->enqueue_event_irq_(RP2GattEvent::WRITE_NO_RSP_DONE, status, 0); +} + +void RP2GattClient::finish_write_no_rsp_(uint8_t status) { + if (this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + return; + } + this->op_type_ = OpType::NONE; + this->listener_->on_write_result(this->op_handle_, status); +} + +void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { + if (this->state_ != EngineState::CONNECTING) { + return; + } + if (status != 0) { + ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status); + this->fail_connection_(status); + return; + } + if (this->cancel_requested_) { + // A disconnect request raced the connection complete and lost; finish + // the teardown instead of reporting a connection nobody wants. + this->con_handle_ = con_handle; + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + uint8_t disc_status; + { + BluetoothLock lock; + disc_status = gap_disconnect(this->con_handle_); + } + if (disc_status != 0) { + this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); + } + return; + } + this->con_handle_ = con_handle; + this->state_ = EngineState::MTU_EXCHANGE; + ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); + BluetoothLock lock; + // One wildcard listener covers notifications/indications for every + // characteristic on this connection; the CCCD writes come from the API + // client as plain descriptor writes. + gatt_client_listen_for_characteristic_value_updates(&this->notification_registration_, + &RP2GattClient::gatt_packet_handler, this->con_handle_, nullptr); + // Auto MTU negotiation is disabled (see rp2040_ble enable hooks), so the + // exchange is kicked explicitly; GATT_EVENT_MTU completes it. Without the + // explicit kick the MTU would only be exchanged on the first GATT query, + // which never happens on a V3_WITH_CACHE connection. + // Both registration calls above return void (BTstack 075a078, arduino-pico + // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // the connect timeout in loop(). + gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); +} + +void RP2GattClient::release_scan_inhibit_() { + if (this->holds_scan_inhibit_) { + this->holds_scan_inhibit_ = false; + this->parent_->release_scan_inhibit(); + } +} + +void RP2GattClient::fail_connection_(uint8_t reason) { + { + // Timeout escalation can fire with the completion event lost; release the + // stack-wide connect slot so pending engines can proceed. Until the old + // completion is processed, gap_connect answers any peer with DISALLOWED + // (the request-level guard in hci.c); a cancel idles that request + // immediately, and a late addressed completion from the old procedure is + // then dropped by the owner-peer cross-check in the handler. + BluetoothLock lock; + if (connect_owner == this) { + connect_owner = nullptr; + } + if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // A success completion stamped the handle between the escalation + // decision and this lock: tear the link down before cleanup wipes the + // handle, or it leaks its pool block for the rest of the boot. + gap_disconnect(this->con_handle_); + } + } + this->cleanup_link_state_(); + this->release_scan_inhibit_(); + this->state_ = EngineState::IDLE; + this->listener_->on_connection_state(false, 0, reason); +} + +void RP2GattClient::cleanup_link_state_() { + // Drop notifications queued behind the disconnect so they cannot emit + // against a freed slot (address 0) on the next loop. + RP2GattNotifyEvent *stale; + while ((stale = this->notify_queue_.pop()) != nullptr) { + this->notify_pool_.release(stale); + } + // con_handle_ may be stamped in the BTstack context before the main loop + // registers the listener, so a valid handle does not imply a registration; + // stop_listening on an unregistered entry is a benign no-op. One lock + // scope around check and reset so an IRQ stamp cannot land in between + // (unreachable today — ownership is released before cleanup — but the + // invariant lives three functions away). + { + BluetoothLock lock; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; + } + this->notify_subscription_count_ = 0; + this->cancel_requested_ = false; + this->op_type_ = OpType::NONE; + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); +} + +void RP2GattClient::handle_disconnected_(uint8_t reason) { + if (this->state_ == EngineState::IDLE) { + return; + } + ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); + this->fail_connection_(reason); +} + +void RP2GattClient::handle_query_complete_(uint8_t att_status) { + // Stale completions cannot cross connections: the loop drains the whole + // event queue every iteration, teardown resets op/discovery state, and a + // new discovery is only issued after the new link's MTU event — which in + // this BTstack emits no QUERY_COMPLETE (the MTU state machine is separate + // from the query state machine). Completions with nothing in flight are + // dropped below. + if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { + OpType op = this->op_type_; + this->op_type_ = OpType::NONE; + switch (op) { + case OpType::READ_CHAR: + case OpType::READ_DESC: + // A value that is an exact multiple of MTU - 1 ends with a trailing + // blob request some peers refuse with INVALID_OFFSET; the read is + // complete, not failed. + if ((att_status == ATT_ERROR_INVALID_OFFSET || att_status == ATT_ERROR_ATTRIBUTE_NOT_LONG) && + this->op_len_ > 0) { + att_status = 0; + } + this->listener_->on_read_result(this->op_handle_, this->op_buffer_, att_status == 0 ? this->op_len_ : 0, + att_status); + break; + case OpType::WRITE_CHAR: + case OpType::WRITE_DESC: + this->listener_->on_write_result(this->op_handle_, att_status); + break; + default: + break; + } + return; + } + if (this->discovery_phase_ != DiscoveryPhase::NONE) { + this->advance_discovery_(att_status); + } +} + +// ---- Service discovery ---- + +int RP2GattClient::discover_services() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (this->arena_ == nullptr) { + // Transient: freed in release_services() right after the table streams + // to the API client (mirrors Bluedroid's own per-connection GATT DB + // lifetime on esp32). Checked: a fragmented heap must surface as a + // stack error the proxy can report, not a device reset. + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_ = allocator.allocate(1); + if (this->arena_ == nullptr) { + ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_); + return ble_device_base::GATT_ERR_NO_MEMORY; + } + new (this->arena_) ServiceArena(); + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; + this->discovery_phase_ = DiscoveryPhase::SERVICES; + BluetoothLock lock; + uint8_t status = gatt_client_discover_primary_services(&RP2GattClient::gatt_packet_handler, this->con_handle_); + if (status != 0) { + this->discovery_phase_ = DiscoveryPhase::NONE; + this->release_services(); + return status; + } + return 0; +} + +int RP2GattClient::issue_characteristic_query_(uint16_t service_index) { + auto &service = this->arena_->services[service_index]; + gatt_client_service_t btstack_service = {}; + btstack_service.start_group_handle = service.start_handle; + btstack_service.end_group_handle = service.end_handle; + service.first_characteristic = this->char_count_; + BluetoothLock lock; + return gatt_client_discover_characteristics_for_service(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_service); +} + +int RP2GattClient::issue_descriptor_query_(uint16_t char_index) { + auto &chr = this->arena_->characteristics[char_index]; + gatt_client_characteristic_t btstack_characteristic = {}; + btstack_characteristic.value_handle = chr.value_handle; + btstack_characteristic.end_handle = chr.end_handle; + chr.first_descriptor = this->desc_count_; + BluetoothLock lock; + return gatt_client_discover_characteristic_descriptors(&RP2GattClient::gatt_packet_handler, this->con_handle_, + &btstack_characteristic); +} + +void RP2GattClient::advance_discovery_(uint8_t att_status) { + if (this->arena_ == nullptr) { + // release_services() is publicly callable; a table freed mid-discovery + // must end the discovery instead of dereferencing a null arena. + this->finish_discovery_(GATT_ERR_NOT_CONNECTED); + return; + } + if (att_status != 0) { + this->finish_discovery_(att_status); + return; + } + switch (this->discovery_phase_) { + case DiscoveryPhase::SERVICES: + if (this->service_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::CHARACTERISTICS; + this->disc_service_cursor_ = 0; + if (int err = this->issue_characteristic_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + case DiscoveryPhase::CHARACTERISTICS: { + auto &service = this->arena_->services[this->disc_service_cursor_]; + service.characteristic_count = this->char_count_ - service.first_characteristic; + this->disc_service_cursor_++; + if (this->disc_service_cursor_ < this->service_count_) { + if (int err = this->issue_characteristic_query_(this->disc_service_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + if (this->char_count_ == 0) { + this->finish_discovery_(0); + return; + } + this->discovery_phase_ = DiscoveryPhase::DESCRIPTORS; + this->disc_char_cursor_ = 0; + if (int err = this->issue_descriptor_query_(0); err != 0) { + this->finish_discovery_(err); + } + break; + } + case DiscoveryPhase::DESCRIPTORS: { + auto &chr = this->arena_->characteristics[this->disc_char_cursor_]; + chr.descriptor_count = this->desc_count_ - chr.first_descriptor; + this->disc_char_cursor_++; + if (this->disc_char_cursor_ < this->char_count_) { + if (int err = this->issue_descriptor_query_(this->disc_char_cursor_); err != 0) { + this->finish_discovery_(err); + } + return; + } + this->finish_discovery_(0); + break; + } + default: + break; + } +} + +void RP2GattClient::finish_discovery_(int error) { + this->discovery_phase_ = DiscoveryPhase::NONE; + ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + error, this->service_count_, this->char_count_, this->desc_count_); + if (error == 0 && this->truncated_) { + // A partial table must not stream: V3 clients cache the database + // permanently, so an incomplete one would be wrong forever. + error = ATT_ERROR_INSUFFICIENT_RESOURCES; + } + if (error == 0) { + // Discovery no longer needs the fast interval; settle into the shared + // steady-state parameters (same lifecycle place as esp32). Status + // discarded: BTstack fails this only for an already-gone handle. + BluetoothLock lock; + gap_update_connection_parameters(this->con_handle_, MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, + MEDIUM_CONN_TIMEOUT); + } + if (this->truncated_) { + ESP_LOGE(TAG, "Service table truncated (device exceeds %u services / %u characteristics / %u descriptors)", + RP2_GATT_MAX_SERVICES, RP2_GATT_MAX_CHARACTERISTICS, RP2_GATT_MAX_DESCRIPTORS); + } + if (error != 0) { + this->release_services(); + } + this->listener_->on_service_discovery_done(error); +} + +ble_device_base::GattServiceTable RP2GattClient::get_service_table() { + ble_device_base::GattServiceTable table; + if (this->arena_ != nullptr) { + table.services = this->arena_->services; + table.characteristics = this->arena_->characteristics; + table.descriptors = this->arena_->descriptors; + table.service_count = this->service_count_; + table.characteristic_count = this->char_count_; + table.descriptor_count = this->desc_count_; + } + return table; +} + +void RP2GattClient::release_services() { + if (this->arena_ != nullptr) { + // Under BluetoothLock so a discovery result landing in the BTstack + // context cannot write into the arena mid-free. + BluetoothLock lock; + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->arena_->~ServiceArena(); + allocator.deallocate(this->arena_, 1); + this->arena_ = nullptr; + } + this->service_count_ = 0; + this->char_count_ = 0; + this->desc_count_ = 0; + this->truncated_ = false; +} + +// ---- Connection control ---- + +int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { + if (this->is_failed()) { + // setup() failed: nothing is registered for event routing and loop() + // never runs, so a connect could not complete or time out. + return GATT_ERR_NOT_CONNECTED; + } + if (this->state_ != EngineState::IDLE) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (!this->parent_->is_active()) { + return GATT_ERR_NOT_CONNECTED; + } + ble_device_base::uint64_to_mac_msb_first(address, this->peer_addr_); + // BLE_ADDR_TYPE_* code space: bit 0 distinguishes public from random + // (resolved RPA types 2/3 connect with the underlying kind). + this->peer_addr_type_ = (addr_type & 1) != 0 ? BD_ADDR_TYPE_LE_RANDOM : BD_ADDR_TYPE_LE_PUBLIC; + + // Stop the shared radio's scan for the duration of the connect attempt + // (esp32 parity: initiating and scanning contend for the radio). + this->holds_scan_inhibit_ = true; + this->parent_->inhibit_scan(); + this->connect_cancel_attempted_ = false; + this->cancel_requested_ = false; + // Bounds the queued wait; restarted when gap_connect is accepted so the + // radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the + // sum via a disconnect request). + this->connect_started_ = millis(); + if (int err = this->try_gap_connect_(); err != 0) { + this->release_scan_inhibit_(); + return err; + } + this->enable_loop(); + return 0; +} + +// One outgoing LE create-connection exists stack-wide: issue it if no other +// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry. +// Returns nonzero only for hard failures (state untouched; caller cleans up). +int RP2GattClient::try_gap_connect_() { + // Unlocked peek: single core, aligned pointer; a stale value costs one loop + // pass and the locked re-check below is authoritative. Keeps the per-loop + // pending retry from taking BluetoothLock just to find the radio busy. + if (connect_owner != nullptr) { + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + uint8_t status; + { + BluetoothLock lock; + if (connect_owner != nullptr) { + status = ERROR_CODE_COMMAND_DISALLOWED; + } else { + // esp32 parity: cached connections come up at MEDIUM already (nothing + // consumes the fast interval without a discovery phase), so there is no + // post-connect update procedure to race or silently lose; sustained + // FAST intervals also starve WiFi on the shared CYW43 radio. + // Without-cache runs FAST for discovery and steps down in + // finish_discovery_. + bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, + cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL, + cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0, + cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (status == 0) { + connect_owner = this; + // Still under the lock: a synthesized failure completion can fire in + // the BTstack context the instant it releases, and completion routing + // requires CONNECTING — set after the fact, the event is discarded + // and the engine burns its whole budget waiting for it. + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + } + } + } + if (status == 0) { + return 0; + } + if (status == ERROR_CODE_COMMAND_DISALLOWED) { + // Radio busy with another engine's connect; resolved from loop(). + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status); + return status; +} + +int RP2GattClient::gatt_disconnect() { + switch (this->state_) { + case EngineState::IDLE: + return GATT_ERR_NOT_CONNECTED; + case EngineState::DISCONNECTING: + return 0; // already on its way down + case EngineState::CONNECT_PENDING: + // Nothing issued stack-side; the invalid handle takes the refused + // path below without touching the stack. + break; + case EngineState::CONNECTING: { + if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { + // The cancel can lose the race against a successful connection + // complete; handle_connected_ checks this flag and finishes the + // teardown instead of proceeding. It also counts as the one cancel + // attempt, so a lost completion escalates on the next timeout tick. + this->cancel_requested_ = true; + this->connect_cancel_attempted_ = true; + // Grace period for the cancel completion: the client's disconnect + // often lands right at the engine's own deadline, and without the + // restart the loop timeout fires first and reports before the + // completion can finish the teardown cleanly. + this->connect_started_ = millis(); + BluetoothLock lock; + // Owner: the cancel completes as a failed connection-complete. Not + // the owner (completion already resolved in the BTstack context): the + // queued event drives the same teardown, nothing to cancel. + if (connect_owner == this) { + gap_connect_cancel(); + } + return 0; + } + break; + } + default: + break; + } + uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + { + BluetoothLock lock; + status = gap_disconnect(this->con_handle_); + } + if (status != 0) { + ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status); + } + } + if (status != 0) { + // Refused (handle already gone) or never issued (CONNECT_PENDING): + // complete via the event queue so the listener cannot re-enter + // disconnect mid-call. BluetoothLock stops the IRQ producer, so this + // main-loop push is SPSC-safe. + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + } + this->state_ = EngineState::DISCONNECTING; + this->disconnecting_started_ = millis(); + // No more initiating: give the radio back to the scanner during teardown. + this->release_scan_inhibit_(); + this->enable_loop(); + return 0; +} + +// ---- GATT operations (single outstanding op) ---- + +int RP2GattClient::read_characteristic(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_CHAR; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + // Long variant: plain read first, blob continuations only past MTU - 1. + uint8_t status = gatt_client_read_long_value_of_characteristic_using_value_handle(&RP2GattClient::gatt_packet_handler, + this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + if (!response) { + // Synchronous in BTstack: the data is copied into the L2CAP buffer before + // the call returns, and no completion event exists — synthesize one so + // the wire behavior matches esp32 (which reports write-no-response too). + uint8_t status; + { + BluetoothLock lock; + if (this->op_type_ == OpType::WRITE_CHAR_NO_RSP) { + // A deferred write is parked; sending now would overtake it. + return GATT_CLIENT_BUSY; + } + status = gatt_client_write_value_of_characteristic_without_response(this->con_handle_, handle, len, + const_cast(data)); + // BTSTACK_ACL_BUFFERS_FULL is the same transient flow control one layer + // down (L2CAP), so it defers identically. + if (status == GATT_CLIENT_BUSY || status == BTSTACK_ACL_BUFFERS_FULL) { + if (this->op_in_flight_()) { + // The op buffer is owned; bounce the busy to the caller as before. + return status; + } + // Stash the payload and send from the can-send callback. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR_NO_RSP; + this->op_handle_ = handle; + this->op_len_ = len; + this->write_no_rsp_started_ = millis(); + this->can_write_registration_.callback = &RP2GattClient::can_write_no_rsp_trampoline; + this->can_write_registration_.context = this; + uint8_t req = gatt_client_request_to_write_without_response(&this->can_write_registration_, this->con_handle_); + if (req != 0 && req != ERROR_CODE_COMMAND_DISALLOWED) { + this->op_type_ = OpType::NONE; + return req; + } + // COMMAND_DISALLOWED = still armed from a timed-out deferral; that + // registration sends the newly parked payload. Keep the loop running + // so the deadline below can fire on a stalled link. + this->enable_loop(); + return 0; + } + } + if (status == 0) { + this->listener_->on_write_result(handle, 0); + } + return status; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + // BTstack keeps the caller's pointer until the request is sent; the payload + // must live in engine-owned storage across the async operation. + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_CHAR; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status; + if (len <= this->mtu_ - 3) { + status = gatt_client_write_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, handle, + len, this->op_buffer_); + } else { + status = gatt_client_write_long_value_of_characteristic(&RP2GattClient::gatt_packet_handler, this->con_handle_, + handle, len, this->op_buffer_); + } + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::read_descriptor(uint16_t handle) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + this->op_type_ = OpType::READ_DESC; + this->op_handle_ = handle; + this->op_len_ = 0; + BluetoothLock lock; + uint8_t status = gatt_client_read_long_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + if (this->op_in_flight_()) { + return GATT_CLIENT_IN_WRONG_STATE; + } + if (len > RP2_GATT_MAX_ATTR_LEN) { + return ATT_ERROR_INVALID_ATTRIBUTE_VALUE_LENGTH; + } + memcpy(this->op_buffer_, data, len); + this->op_type_ = OpType::WRITE_DESC; + this->op_handle_ = handle; + BluetoothLock lock; + uint8_t status = gatt_client_write_characteristic_descriptor_using_descriptor_handle( + &RP2GattClient::gatt_packet_handler, this->con_handle_, handle, len, this->op_buffer_); + if (status != 0) { + this->op_type_ = OpType::NONE; + return status; + } + return 0; +} + +int RP2GattClient::pair() { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + sm_request_pairing(this->con_handle_); // void API; completion via SM events + return 0; +} + +int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + // The CCCD write arrives separately as a descriptor write (V3 semantics); + // this call only gates local delivery via the subscription list. + if (enable) { + if (!this->notify_subscribed_(handle)) { + if (this->notify_subscription_count_ >= RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS) { + return GATT_ERR_NO_MEMORY; + } + this->notify_subscriptions_[this->notify_subscription_count_++] = handle; + } + } else { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + this->notify_subscriptions_[i] = this->notify_subscriptions_[--this->notify_subscription_count_]; + break; + } + } + } + this->listener_->on_notify_state(handle, enable, 0); + return 0; +} + +bool RP2GattClient::notify_subscribed_(uint16_t handle) const { + for (uint8_t i = 0; i < this->notify_subscription_count_; i++) { + if (this->notify_subscriptions_[i] == handle) { + return true; + } + } + return false; +} + +int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + if (this->state_ != EngineState::READY) { + return GATT_ERR_NOT_CONNECTED; + } + BluetoothLock lock; + return gap_update_connection_parameters(this->con_handle_, min_interval, max_interval, latency, timeout); +} + +conn_err_t unpair_device(uint64_t address) { + uint8_t mac[MAC_ADDRESS_SIZE]; + ble_device_base::uint64_to_mac_msb_first(address, mac); + bool found = false; + BluetoothLock lock; + // Exhaustive: the db keys on (type, address), so stale entries can share + // the same address bytes under different types. + for (int i = 0; i < le_device_db_max_count(); i++) { + int addr_type = 0; + bd_addr_t addr; + le_device_db_info(i, &addr_type, addr, nullptr); + if (addr_type != BD_ADDR_TYPE_UNKNOWN && memcmp(addr, mac, sizeof(bd_addr_t)) == 0) { + le_device_db_remove(i); + found = true; + } + } + if (found) { + return CONN_OK; + } + // No bond for this address; the shared error domain has no closer code + // (esp32 parity: its remove-bond call also errors for an unknown address). + return GATT_NOT_CONNECTED; +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h new file mode 100644 index 0000000000..4d407269b6 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -0,0 +1,251 @@ +// RP2 (Pico W / Pico 2 W) GATT client backend over BTstack. +// +// The build's ble_device_base::BLEGattConnection backend (bound by alias in +// bluetooth_connection_gatt_backend.h) for the hub BluetoothConnection wrapper. BTstack packet handlers run in the +// CYW43 async-context low-priority IRQ (or on the main-loop stack during BluetoothLock release), so handlers only copy +// into per-instance lock-free queues/storage; loop() drains them and drives the state machine. Every BTstack call +// issued from the main loop is wrapped in BluetoothLock. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/rp2040_ble/rp2040_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +#include + +#include +#include + +namespace esphome::bluetooth_connection { + +// Caps for the transient service table. Sized generously for real devices +// (typical peripherals expose < 8 services / < 30 characteristics); a peer +// exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than +// streaming an incomplete database a V3 client would cache permanently. +static constexpr uint16_t RP2_GATT_MAX_SERVICES = 16; +static constexpr uint16_t RP2_GATT_MAX_CHARACTERISTICS = 96; +static constexpr uint16_t RP2_GATT_MAX_DESCRIPTORS = 96; + +// Concurrent notify subscriptions per connection (enable fails with +// GATT_ERR_NO_MEMORY when exceeded; real clients subscribe to a handful). +static constexpr uint8_t RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS = 16; + +// ATT spec maximum attribute value length; bounds the op buffer and +// notification payloads. +static constexpr uint16_t RP2_GATT_MAX_ATTR_LEN = 512; + +// Control events from the BTstack handlers to loop(). +struct RP2GattEvent { + enum Type : uint8_t { + CONNECTED, // status + con_handle (value) + DISCONNECTED, // status = HCI reason + MTU_EXCHANGED, // value = negotiated MTU + QUERY_COMPLETE, // status = ATT status of the finished query + WRITE_NO_RSP_DONE, // status = result of the deferred write + PAIRING_RESULT, // status = SM pairing status (0 = bonded) + }; + Type type; + uint8_t status; + uint16_t value; + void release() {} +}; + +// One notification/indication from the peer. +struct RP2GattNotifyEvent { + uint16_t handle; + uint16_t len; + uint8_t data[RP2_GATT_MAX_ATTR_LEN]; + void release() {} +}; + +static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; +// Depth 4: the queue is drained every main-loop iteration and each slot is a +// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. +static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; + +class RP2GattClient final : public Component, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + // Teardown starts inside gatt_disconnect() on this backend; nothing is + // ever scheduled, so there is nothing to cancel. + bool cancel_gatt_disconnect() { return false; } + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + ble_device_base::GattServiceTable get_service_table(); + // Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is + // reserved for the discovery phase of uncached connects. + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + void release_services(); + +#ifdef USE_OTA_STATE_LISTENER + // Drop the connection while an OTA runs (esp32 parity): an active link + // competes with the transfer for the shared radio. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + protected: + // Link/engine state. Discovery and GATT ops have their own cursors below — + // the link stays READY while they run. + enum class EngineState : uint8_t { + IDLE, + CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered + DISCONNECTING, + }; + + enum class DiscoveryPhase : uint8_t { NONE, SERVICES, CHARACTERISTICS, DESCRIPTORS }; + + enum class OpType : uint8_t { NONE, READ_CHAR, WRITE_CHAR, WRITE_CHAR_NO_RSP, READ_DESC, WRITE_DESC }; + + // The whole table in one transient allocation (RAMAllocator, checked), + // freed after streaming. + struct ServiceArena { + ble_device_base::GattService services[RP2_GATT_MAX_SERVICES]; + ble_device_base::GattCharacteristic characteristics[RP2_GATT_MAX_CHARACTERISTICS]; + ble_device_base::GattDescriptor descriptors[RP2_GATT_MAX_DESCRIPTORS]; + }; + + // BTstack packet handlers (IRQ context: copy-and-enqueue only). + static void hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void gatt_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static void sm_packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size); + static RP2GattClient *instance_for_con_handle(hci_con_handle_t con_handle); + + void handle_gatt_event_irq_(uint8_t event_type, const uint8_t *packet); + void enqueue_event_irq_(RP2GattEvent::Type type, uint8_t status, uint16_t value); + void enqueue_notify_irq_(uint16_t handle, const uint8_t *data, uint16_t len); + void assemble_blob_irq_(uint16_t offset, const uint8_t *data, uint16_t len); + + // Main-loop state machine. + void handle_event_(const RP2GattEvent &event); + void handle_connected_(uint8_t status, uint16_t con_handle); + void handle_disconnected_(uint8_t reason); + void handle_query_complete_(uint8_t att_status); + void advance_discovery_(uint8_t att_status); + int issue_characteristic_query_(uint16_t service_index); + int issue_descriptor_query_(uint16_t char_index); + void finish_discovery_(int error); + void fail_connection_(uint8_t reason); + int try_gap_connect_(); + void cleanup_link_state_(); + bool notify_subscribed_(uint16_t handle) const; + static void can_write_no_rsp_trampoline(void *context); + void finish_write_no_rsp_(uint8_t status); + void release_scan_inhibit_(); + bool op_in_flight_() const { + return this->op_type_ != OpType::NONE || this->discovery_phase_ != DiscoveryPhase::NONE; + } + + // Group 1: containers / large storage + ble_device_base::GattClientListener *listener_{nullptr}; + ServiceArena *arena_{nullptr}; + esphome::LockFreeQueue event_queue_; + esphome::EventPool event_pool_; + esphome::LockFreeQueue notify_queue_; + esphome::EventPool notify_pool_; + + // Shared buffer for the single outstanding GATT op: write payloads (BTstack + // keeps the caller's pointer until the request is sent) and read results + // (written from the handler, read after QUERY_COMPLETE is drained). + uint8_t op_buffer_[RP2_GATT_MAX_ATTR_LEN]; + + // BTstack registrations + gatt_client_notification_t notification_registration_{}; + btstack_context_callback_registration_t can_write_registration_{}; + + // Group 3: 4-byte types + uint32_t connect_started_{0}; + uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt + uint32_t disconnecting_started_{0}; + uint32_t write_no_rsp_started_{0}; + // Unscoped C enum, so int-sized: lives with the 4-byte members to keep the + // padding at the tail. + bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + + // Group 4: 2-byte types (table counters written from the handler during + // discovery, read from the main loop after the phase's QUERY_COMPLETE) + hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; + uint16_t op_handle_{0}; + uint16_t op_len_{0}; + uint16_t service_count_{0}; + uint16_t char_count_{0}; + uint16_t desc_count_{0}; + uint16_t disc_service_cursor_{0}; + uint16_t disc_char_cursor_{0}; + + // Group 5: arrays / 1-byte types + // Subscribed notify handles; the loop() drain filters the wildcard + // listener's deliveries on this list (esp32 parity for enable=false). + std::array notify_subscriptions_{}; + uint8_t notify_subscription_count_{0}; + uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE}; + EngineState state_{EngineState::IDLE}; + DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; + OpType op_type_{OpType::NONE}; + bool truncated_{false}; + // One cancel attempt per connect: the second timeout escalates to failure. + bool connect_cancel_attempted_{false}; + // A disconnect request raced an in-flight connect; finish teardown on link-up. + bool cancel_requested_{false}; + // This engine's own hold on the shared scan inhibit, so the pairing stays + // one-to-one per connection even with multiple slots. + bool holds_scan_inhibit_{false}; + + // Instance registry for routing BTstack events (IRQ context) to engines. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *instances[ESPHOME_BLE_GATT_CLIENT_COUNT]; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static uint8_t instance_count; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t hci_event_registration; + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static btstack_packet_callback_registration_t sm_event_registration; + // The engine whose gap_connect is in flight: BTstack allows one outgoing LE + // create-connection stack-wide, and gap_connect_cancel is global, so only + // the owner may cancel. Written under BluetoothLock from the main loop, + // cleared in the BTstack context when the procedure resolves. + // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) + static RP2GattClient *connect_owner; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ad7528c156..1b761849a5 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -1,14 +1,62 @@ +import functools import logging import esphome.codegen as cg -from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker -from esphome.components.esp32 import add_idf_sdkconfig_option -from esphome.components.esp32_ble import BTLoggers +from esphome.components import ble_device_base, bluetooth_connection import esphome.config_validation as cv -from esphome.const import CONF_ACTIVE, CONF_ID +from esphome.const import ( + CONF_ACTIVE, + CONF_ID, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_LN882X, + PLATFORM_RP2, +) +from esphome.core import CORE +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"] -DEPENDENCIES = ["api", "esp32"] +# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily +# inside _esp32_config_schema()/_to_code_esp32(): importing those modules +# registers esp32-only automations (ble.enable, ble.disable, ...) as a side +# effect, and a module-scope import would leak them into every platform's +# registry the moment a config declares `bluetooth_proxy:` — degrading +# "Unable to find action" config errors into C++ compile failures. + + +def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: + """Components to auto-load for the platform being compiled. + + Callable with no argument so tooling that resolves AUTO_LOAD without a + target platform (the device-builder catalog sync does exactly this) gets + the union of every arm instead of an empty list — which is what lets it + keep cross-referencing the esp32 BLE stack. A real build always has a + target platform set, so it takes one of the concrete branches. + """ + if CORE.is_esp32: + return ["bluetooth_connection", "esp32_ble_tracker"] + if CORE.target_platform in _HUB_PLATFORMS: + return ["ble_device_base", "bluetooth_connection"] + # No target platform, or one this component does not support: tooling + # resolving the manifest (including the host-pinned dependency resolver) — + # expose every arm so the closure keeps the esp32 BLE stack. + return [ + "ble_device_base", + "bluetooth_connection", + "esp32_ble_tracker", + ] + + +# Platforms with an in-tree ble_device_base BLE tracker hub whose controller +# supports active scanning — every current client (aioesphomeapi, bleak-esphome, +# Home Assistant) assumes an ESPHome proxy can scan actively, so a passive-only +# hub must not be admitted (it would be misdriven). +# Coupled to bluetooth_connection: platforms with a GATT backend are also +# listed in its _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and +# FILTER_SOURCE_FILES hub entry. +_HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) + +DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] _LOGGER = logging.getLogger(__name__) @@ -20,65 +68,302 @@ DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") -BluetoothProxy = bluetooth_proxy_ns.class_( - "BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component -) -BluetoothConnection = bluetooth_proxy_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase -) +BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component) -CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } -).extend(cv.COMPONENT_SCHEMA) +# Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32 +# schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/ +# pins them together, and the outer walkable schema uses it as the +# connection_slots bound (per-platform schemas tighten it). +_IDF_MAX_CONNECTIONS = 9 -def validate_connections(config): - if CONF_CONNECTIONS in config: - if not config[CONF_ACTIVE]: - raise cv.Invalid( - "Connections can only be used if the proxy is set to active" +@functools.cache +def _esp32_config_schema() -> cv.All: + """Build the esp32 schema, importing the esp32 BLE stack only when used.""" + from esphome.components import esp32_ble, esp32_ble_tracker + + if esp32_ble.IDF_MAX_CONNECTIONS != _IDF_MAX_CONNECTIONS: + raise cv.Invalid( + f"bluetooth_proxy's connection-slot limit mirror " + f"({_IDF_MAX_CONNECTIONS}) is out of sync with " + f"esp32_ble.IDF_MAX_CONNECTIONS ({esp32_ble.IDF_MAX_CONNECTIONS}); " + f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" + ) + + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32) + + def validate_connections(config: ConfigType) -> ConfigType: + if CONF_CONNECTIONS in config: + if not config[CONF_ACTIVE]: + raise cv.Invalid( + "Connections can only be used if the proxy is set to active" + ) + elif config[CONF_ACTIVE]: + connection_slots: int = config[CONF_CONNECTION_SLOTS] + esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")( + config ) - elif config[CONF_ACTIVE]: - connection_slots: int = config[CONF_CONNECTION_SLOTS] - esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) - return { - **config, - CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], - } + return { + **config, + CONF_CONNECTIONS: [ + CONNECTION_SCHEMA({}) for _ in range(connection_slots) + ], + } + return config + + return cv.All( + ( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + cv.Optional(CONF_ACTIVE, default=True): cv.boolean, + cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, + cv.Optional( + CONF_CONNECTION_SLOTS, + default=DEFAULT_CONNECTION_SLOTS, + ): cv.All( + cv.positive_int, + cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + ), + cv.Optional(CONF_CONNECTIONS): cv.All( + cv.ensure_list(CONNECTION_SCHEMA), + cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ), + validate_connections, + ) + + +def _validate_no_active(config: ConfigType) -> ConfigType: + if config[CONF_ACTIVE]: + raise cv.Invalid( + "Active connections are not supported on this platform; the proxy " + "forwards advertisements only (set active: false)" + ) return config -CONFIG_SCHEMA = cv.All( - ( +@functools.cache +def _rp2_config_schema() -> cv.All: + """Full proxy on the rp2 BLE hub: active connections through the BTstack + GATT client backend in bluetooth_connection. Multi-slot builds replace the + prebuilt library's one-client BTstack pools via linker --wrap, owned by + rp2040_ble and requested when a second backend registers.""" + connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) + + def populate_connections(config: ConfigType) -> ConfigType: + from esphome.components import rp2040_ble + + # One wrapper + backend pair per slot, declared during validation so + # their ids exist for codegen (the esp32 arm's `connections` pattern). + if not config[CONF_ACTIVE]: + return config + connection_slots: int = config[CONF_CONNECTION_SLOTS] + rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) + return { + **config, + CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)], + } + + max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] + schema = ( cv.Schema( { - cv.GenerateID(): cv.declare_id(BluetoothProxy), + **_COMMON_SCHEMA_KEYS, cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, - default=DEFAULT_CONNECTION_SLOTS, + default=min(DEFAULT_CONNECTION_SLOTS, max_conn), ): cv.All( cv.positive_int, - cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), - ), - cv.Optional(CONF_CONNECTIONS): cv.All( - cv.ensure_list(CONNECTION_SCHEMA), - cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), + cv.Range( + min=1, + max=max_conn, + msg=f"rp2 supports at most {max_conn} connection slot(s); " + "the BTstack pool overrides in rp2040_ble are sized " + f"for {max_conn}", + ), ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) .extend(cv.COMPONENT_SCHEMA) - ), - validate_connections, + ) + return cv.All(schema, populate_connections) + + +async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + """One wrapper + backend pair per slot; the platform-specific backend + registration lives in bluetooth_connection.new_gatt_backend().""" + connections = config.get(CONF_CONNECTIONS, []) + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present (zero on advertisement-only + # hubs); sized here so it can never diverge from the loop below. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + if connections: + # Gates the connection and GATT half of the API surface. A proxy + # without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never + # sends those requests and their handlers and encoders are dead. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") + for connection_conf in connections: + backend = await bluetooth_connection.new_gatt_backend(connection_conf) + connection = cg.new_Pvariable(connection_conf[CONF_ID]) + cg.add(connection.set_backend(backend)) + cg.add(var.register_connection(connection)) + + +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. +_GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} + + +# Keys every platform arm declares identically; each arm spreads this dict so +# the shared surface cannot drift. CONF_ACTIVE stays per-arm: its default +# differs (esp32 True, rp2 True, advertisement-only False). +_COMMON_SCHEMA_KEYS = { + cv.GenerateID(): cv.declare_id(BluetoothProxy), +} + +# Advertisement-only proxy on a neutral BLE hub: the hub's raw-advertisement +# callback feeds the same API batching, no connection stack compiled. +_BLE_HUB_CONFIG_SCHEMA = cv.All( + cv.Schema( + { + **_COMMON_SCHEMA_KEYS, + cv.Optional(CONF_ACTIVE, default=False): cv.boolean, + } + ) + .extend( + # ble_hub_id with the friendly no-tracker-configured guard. + ble_device_base.BLE_DEVICE_SCHEMA + ) + .extend(cv.COMPONENT_SCHEMA), + _validate_no_active, ) -async def to_code(config): +@schema_extractor("schema") +def _validate_platform(config: ConfigType) -> ConfigType: + """Apply the schema for the platform actually being compiled. + + Three-way dispatch: esp32 gets the full GATT proxy, HUB_MAX_CONNECTIONS + platforms get their _GATT_HUB_SCHEMAS arm, the remaining hub platforms get + the advertisement-only shape; unsupported keys were already rejected by + name in _reject_unsupported_connection_keys. + """ + if config is SCHEMA_EXTRACT: + # The language-schema dumper runs without a platform. Expose the esp32 + # shape so `connections`, the ids and every default stay in the + # generated schema the editor and dashboard consume. + return _esp32_config_schema() + if CORE.is_esp32: + return _esp32_config_schema()(config) + if CORE.target_platform not in _HUB_PLATFORMS: + # Fail here with the actual reason. Without this gate the error surfaces + # later as an unresolvable hub ID ("Are you missing a hub declaration?") + # on platforms where no hub component can be declared. + full = ", ".join(["esp32", *sorted(bluetooth_connection.HUB_MAX_CONNECTIONS)]) + adv_only = ", ".join( + sorted(set(_HUB_PLATFORMS) - set(bluetooth_connection.HUB_MAX_CONNECTIONS)) + ) + raise cv.Invalid( + f"bluetooth_proxy is not supported on {CORE.target_platform}: no " + "active-scan-capable BLE tracker hub is available for this " + f"platform. It runs on {full} (full proxy) and {adv_only} " + "(advertisement-only)." + ) + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + return _GATT_HUB_SCHEMAS[CORE.target_platform]()(config) + return _BLE_HUB_CONFIG_SCHEMA(config) + + +def _reject_unsupported_connection_keys(config: ConfigType) -> ConfigType: + """Reject connection options a platform does not support, by name. + + GATT hub platforms keep connection_slots but reject the esp32-only keys; + advertisement-only hubs reject all three. Runs before the walkable schema + below so the user gets "this option does not exist here" instead of a + value-range error implying the option works. + """ + if not isinstance(config, dict) or CORE.is_esp32 or CORE.target_platform is None: + return config + if CORE.target_platform not in _HUB_PLATFORMS: + # No proxy of any kind exists here: fall through so _validate_platform + # reports "not supported on {platform}" instead of a key-level message + # implying an advertisement-only proxy is available. + return config + if CORE.target_platform in bluetooth_connection.HUB_MAX_CONNECTIONS: + # Full proxy: connection_slots is real here; the per-connection list + # exists internally but carries no user options, and the Bluedroid + # NVS service cache is esp32-only. + rejected = { + CONF_CONNECTIONS: ( + "has no per-connection options on this platform; use " + "'connection_slots' to set the count" + ), + CONF_CACHE_SERVICES: "is esp32-only (Bluedroid NVS service cache)", + } + else: + reason = ( + "requires active connection support; this platform runs the " + "advertisement-only proxy and has no such option" + ) + rejected = dict.fromkeys( + (CONF_CONNECTION_SLOTS, CONF_CACHE_SERVICES, CONF_CONNECTIONS), reason + ) + for key, reason in rejected.items(): + if key in config: + raise cv.Invalid(f"'{key}' {reason}", path=[key]) + return config + + +# CONFIG_SCHEMA stays a statically walkable schema: tooling (the dashboard's +# field-range extractor among others) introspects it to discover options and +# their bounds, which a bare dispatch function would hide. It carries the scalar +# keys with no defaults; _validate_platform then runs the real per-platform +# schema, which applies the defaults and rejects options the platform does not +# support. +# +# It deliberately does NOT declare `connections`: this outer schema runs before +# the per-platform one, so any key it transforms is transformed twice. Running +# CONNECTION_SCHEMA twice re-validates an already-generated ID through +# declare_id(), which (unlike use_id) has no guard for an ID instance and +# rejects it as empty. extra=ALLOW_EXTRA passes `connections` through untouched +# for _ESP32_CONFIG_SCHEMA to validate exactly once. +CONFIG_SCHEMA = cv.All( + _reject_unsupported_connection_keys, + cv.Schema( + { + cv.Optional(CONF_ACTIVE): cv.boolean, + cv.Optional(CONF_CACHE_SERVICES): cv.boolean, + # Bounded by the loosest platform cap so range walkers (the + # device-builder field-range sync) see a real Range; the + # per-platform schemas tighten it (1 on rp2) with their own error. + cv.Optional(CONF_CONNECTION_SLOTS): cv.All( + cv.positive_int, + cv.Range(min=1, max=_IDF_MAX_CONNECTIONS), + ), + }, + extra=cv.ALLOW_EXTRA, + ), + _validate_platform, +) + + +async def _to_code_esp32(config: ConfigType) -> None: + from esphome.components import esp32_ble, esp32_ble_tracker + from esphome.components.esp32 import add_idf_sdkconfig_option + from esphome.components.esp32_ble import BTLoggers + # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP) @@ -86,11 +371,35 @@ async def to_code(config): await cg.register_component(var, config) cg.add(var.set_active(config[CONF_ACTIVE])) - await esp32_ble_tracker.register_raw_ble_device(var, config) + tracker = await cg.get_variable(config[esp32_ble_tracker.CONF_ESP32_BLE_ID]) + cg.add(var.set_ble_hub(tracker)) - # Define max connections for protobuf fixed array - connection_count = len(config.get(CONF_CONNECTIONS, [])) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) + # Compiles the scanner-state push slot into the tracker and the matching + # registration into the proxy; the other hubs are polled instead. + cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") + + await _connections_to_code(var, config) + + if config.get(CONF_CACHE_SERVICES): + add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) + + +async def _to_code_ble_hub(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_active(config[CONF_ACTIVE])) + hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) + cg.add(var.set_ble_hub(hub)) + + await _connections_to_code(var, config) + + +async def to_code(config: ConfigType) -> None: + if CORE.is_esp32: + await _to_code_esp32(config) + else: + await _to_code_ble_hub(config) # Define batch size for BLE advertisements # Each advertisement is up to 80 bytes when packaged (including protocol overhead) @@ -98,13 +407,4 @@ async def to_code(config): # This achieves ~97% WiFi MTU utilization while staying under the limit cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) - for connection_conf in config.get(CONF_CONNECTIONS, []): - connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) - await cg.register_component(connection_var, connection_conf) - cg.add(var.register_connection(connection_var)) - await esp32_ble_tracker.register_raw_client(connection_var, connection_conf) - - if config.get(CONF_CACHE_SERVICES): - add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) - cg.add_define("USE_BLUETOOTH_PROXY") diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp deleted file mode 100644 index 21573f0184..0000000000 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ /dev/null @@ -1,589 +0,0 @@ -#include "bluetooth_connection.h" - -#include "esphome/components/api/api_pb2.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include "bluetooth_proxy.h" - -namespace esphome::bluetooth_proxy { - -static const char *const TAG = "bluetooth_proxy.connection"; - -// This function is allocation-free and directly packs UUIDs into the output array -// using precalculated constants for the Bluetooth base UUID -static void fill_128bit_uuid_array(std::array &out, esp_bt_uuid_t uuid_source) { - // Bluetooth base UUID: 00000000-0000-1000-8000-00805F9B34FB - // out[0] = bytes 8-15 (big-endian) - // - For 128-bit UUIDs: use bytes 8-15 as-is - // - For 16/32-bit UUIDs: insert into bytes 12-15, use 0x00001000 for bytes 8-11 - out[0] = uuid_source.len == ESP_UUID_LEN_128 - ? (((uint64_t) uuid_source.uuid.uuid128[15] << 56) | ((uint64_t) uuid_source.uuid.uuid128[14] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[13] << 40) | ((uint64_t) uuid_source.uuid.uuid128[12] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[11] << 24) | ((uint64_t) uuid_source.uuid.uuid128[10] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[9] << 8) | ((uint64_t) uuid_source.uuid.uuid128[8])) - : (((uint64_t) (uuid_source.len == ESP_UUID_LEN_16 ? uuid_source.uuid.uuid16 : uuid_source.uuid.uuid32) - << 32) | - 0x00001000ULL); // Base UUID bytes 8-11 - // out[1] = bytes 0-7 (big-endian) - // - For 128-bit UUIDs: use bytes 0-7 as-is - // - For 16/32-bit UUIDs: use precalculated base UUID constant - out[1] = uuid_source.len == ESP_UUID_LEN_128 - ? ((uint64_t) uuid_source.uuid.uuid128[7] << 56) | ((uint64_t) uuid_source.uuid.uuid128[6] << 48) | - ((uint64_t) uuid_source.uuid.uuid128[5] << 40) | ((uint64_t) uuid_source.uuid.uuid128[4] << 32) | - ((uint64_t) uuid_source.uuid.uuid128[3] << 24) | ((uint64_t) uuid_source.uuid.uuid128[2] << 16) | - ((uint64_t) uuid_source.uuid.uuid128[1] << 8) | ((uint64_t) uuid_source.uuid.uuid128[0]) - : 0x800000805F9B34FBULL; // Base UUID bytes 0-7: 80-00-00-80-5F-9B-34-FB -} - -// Helper to fill UUID in the appropriate format based on client support and UUID type -static void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uuid, const esp_bt_uuid_t &uuid, - bool use_efficient_uuids) { - if (!use_efficient_uuids || uuid.len == ESP_UUID_LEN_128) { - // Use 128-bit format for old clients or when UUID is already 128-bit - fill_128bit_uuid_array(uuid_128, uuid); - } else if (uuid.len == ESP_UUID_LEN_16) { - short_uuid = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - short_uuid = uuid.uuid.uuid32; - } -} - -// Constants for size estimation -static constexpr uint8_t SERVICE_OVERHEAD_LEGACY = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t SERVICE_OVERHEAD_EFFICIENT = 10; // UUID(6) + handle(4) -static constexpr uint8_t CHAR_SIZE_128BIT = 35; // UUID(20) + handle(4) + props(4) + overhead(7) -static constexpr uint8_t DESC_SIZE_128BIT = 25; // UUID(20) + handle(4) + overhead(1) -static constexpr uint8_t DESC_SIZE_16BIT = 10; // UUID(6) + handle(4) -static constexpr uint8_t DESC_PER_CHAR = 1; // Assume 1 descriptor per characteristic - -// Helper to estimate service size before fetching all data -/** - * Estimate the size of a Bluetooth service based on the number of characteristics and UUID format. - * - * @param char_count The number of characteristics in the service. - * @param use_efficient_uuids Whether to use efficient UUIDs (16-bit or 32-bit) for newer APIVersions. - * @return The estimated size of the service in bytes. - * - * This function calculates the size of a Bluetooth service by considering: - * - A service overhead, which depends on whether efficient UUIDs are used. - * - The size of each characteristic, assuming 128-bit UUIDs for safety. - * - The size of descriptors, assuming one 128-bit descriptor per characteristic. - */ -static size_t estimate_service_size(uint16_t char_count, bool use_efficient_uuids) { - size_t service_overhead = use_efficient_uuids ? SERVICE_OVERHEAD_EFFICIENT : SERVICE_OVERHEAD_LEGACY; - // Always assume 128-bit UUIDs for characteristics to be safe - size_t char_size = CHAR_SIZE_128BIT; - // Assume one 128-bit descriptor per characteristic - size_t desc_size = DESC_SIZE_128BIT * DESC_PER_CHAR; - - return service_overhead + (char_size + desc_size) * char_count; -} - -bool BluetoothConnection::supports_efficient_uuids_() const { - auto *api_conn = this->proxy_->get_api_connection(); - return api_conn && api_conn->client_supports_api_version(1, 12); -} - -void BluetoothConnection::dump_config() { - ESP_LOGCONFIG(TAG, "BLE Connection:"); - BLEClientBase::dump_config(); -} - -void BluetoothConnection::update_allocated_slot_(uint64_t find_value, uint64_t set_value) { - auto &allocated = this->proxy_->connections_free_response_.allocated; - for (auto &slot : allocated) { - if (slot == find_value) { - slot = set_value; - return; - } - } -} - -void BluetoothConnection::set_address(uint64_t address) { - // If we're clearing an address (disconnecting), update the pre-allocated message - if (address == 0 && this->address_ != 0) { - this->proxy_->connections_free_response_.free++; - this->update_allocated_slot_(this->address_, 0); - } - // If we're setting a new address (connecting), update the pre-allocated message - else if (address != 0 && this->address_ == 0) { - this->proxy_->connections_free_response_.free--; - this->update_allocated_slot_(0, address); - } - - // Call parent implementation to actually set the address - BLEClientBase::set_address(address); -} - -void BluetoothConnection::loop() { - BLEClientBase::loop(); - - // Early return if no active connection - if (this->address_ == 0) { - return; - } - - // Handle service discovery if in valid range - if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { - this->send_service_for_discovery_(); - } - - // Check if we should disable the loop - // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete - // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { - this->disable_loop(); - } -} - -void BluetoothConnection::reset_connection_(esp_err_t reason) { - // Send disconnection notification - this->proxy_->send_device_connection(this->address_, false, 0, reason); - - // Important: If we were in the middle of sending services, we do NOT send - // send_gatt_services_done() here. This ensures the client knows that - // the service discovery was interrupted and can retry. The client - // (aioesphomeapi) implements a 30-second timeout (DEFAULT_BLE_TIMEOUT) - // to detect incomplete service discovery rather than relying on us to - // tell them about a partial list. - this->set_address(0); - this->send_service_ = INIT_SENDING_SERVICES; - this->proxy_->send_connections_free(); -} - -void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ >= this->service_count_) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); - this->release_services(); - return; - } - - // Early return if no API connection - auto *api_conn = this->proxy_->get_api_connection(); - if (api_conn == nullptr) { - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->supports_efficient_uuids_(); - - // Prepare response - api::BluetoothGATTGetServicesResponse resp; - resp.address = this->address_; - - // Dynamic batching based on actual size - // Conservative MTU limit for API messages (accounts for WPA3 overhead) - static constexpr size_t MAX_PACKET_SIZE = 1360; - - // Keep running total of actual message size - size_t current_size = resp.calculate_size(); - - while (this->send_service_ < this->service_count_) { - esp_gattc_service_elem_t service_result; - uint16_t service_count = 1; - esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, - &service_result, &service_count, this->send_service_); - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Get the number of characteristics BEFORE adding to response - uint16_t total_char_count = 0; - esp_gatt_status_t char_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); - - if (char_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // If this service likely won't fit, send current batch (unless it's the first) - size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); - if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { - // This service likely won't fit, send current batch - break; - } - - // Now add the service since we know it will likely fit - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, service_result.uuid, use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - if (total_char_count > 0) { - // Initialize FixedVector with exact count and process characteristics - service_resp.characteristics.init(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - while (true) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, char_result.uuid, use_efficient_uuids); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - continue; - } - - // Initialize FixedVector with exact count and process descriptors - characteristic_resp.descriptors.init(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (true) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { - break; - } - if (desc_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (desc_count == 0) { - break; // No more descriptors - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, desc_result.uuid, use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } - } - } // end if (total_char_count > 0) - - // Calculate the actual size of just this service - size_t service_size = service_resp.calculate_size() + 1; // +1 for field tag - - // Check if adding this service would exceed the limit - if (current_size + service_size > MAX_PACKET_SIZE) { - // We would go over - pop the last service if we have more than one - if (resp.services.size() > 1) { - resp.services.pop_back(); - ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", - this->connection_index_, this->address_str(), this->send_service_, current_size, service_size, - MAX_PACKET_SIZE); - // Don't increment send_service_ - we'll retry this service in next batch - } else { - // This single service is too large, but we have to send it anyway - ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, - this->address_str(), this->send_service_, service_size); - // Increment so we don't get stuck - this->send_service_++; - } - // Send what we have - break; - } - - // Now we know we're keeping this service, add its size - current_size += service_size; - // Successfully added this service, increment counter - this->send_service_++; - } - - // Send the message with dynamically batched services - api_conn->send_message(resp); -} - -void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); -} - -void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); -} - -void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, - type); -} - -void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), - operation, handle, status); -} - -esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { - if (err != ESP_OK) { - this->log_connection_warning_(operation, err); - return err; - } - return ESP_OK; -} - -bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) - return false; - - switch (event) { - case ESP_GATTC_DISCONNECT_EVT: { - // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources - // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, - param->disconnect.reason); - // Send disconnection notification but don't free the slot yet - this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); - break; - } - case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, - param->close.reason); - // Now the GATT connection is fully closed and controller resources are freed - // Safe to mark the connection slot as available - this->reset_connection_(param->close.reason); - break; - } - case ESP_GATTC_OPEN_EVT: { - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - this->reset_connection_(param->open.status); - } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - } - this->seen_mtu_or_services_ = false; - break; - } - case ESP_GATTC_CFG_MTU_EVT: - case ESP_GATTC_SEARCH_CMPL_EVT: { - if (!this->seen_mtu_or_services_) { - // We don't know if we will get the MTU or the services first, so - // only send the device connection true if we have already received - // the services. - this->seen_mtu_or_services_ = true; - break; - } - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - break; - } - case ESP_GATTC_READ_DESCR_EVT: - case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); - this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTReadResponse resp; - resp.address = this->address_; - resp.handle = param->read.handle; - resp.set_data(param->read.value, param->read.value_len); - api_connection->send_message(resp); - break; - } - case ESP_GATTC_WRITE_CHAR_EVT: - case ESP_GATTC_WRITE_DESCR_EVT: { - if (param->write.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); - this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = param->write.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (param->unreg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, - param->unreg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->unreg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - if (param->reg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, - param->reg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->reg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, - param->notify.handle); - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyDataResponse resp; - resp.address = this->address_; - resp.handle = param->notify.handle; - resp.set_data(param->notify.value, param->notify.value_len); - api_connection->send_message(resp); - break; - } - default: - break; - } - return true; -} - -void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - BLEClientBase::gap_event_handler(event, param); - - switch (event) { - case ESP_GAP_BLE_AUTH_CMPL_EVT: - if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0) - break; - if (param->ble_security.auth_cmpl.success) { - this->proxy_->send_device_pairing(this->address_, true); - } else { - this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason); - } - break; - default: - break; - } -} - -esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "characteristic"); - return ESP_GATT_NOT_CONNECTED; - } - - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char", err); -} - -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, - bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "characteristic"); - return ESP_GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char", err); -} - -esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "descriptor"); - return ESP_GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); -} - -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "descriptor"); - return ESP_GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); -} - -esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { - if (!this->connected()) { - this->log_gatt_not_connected_("notify", "characteristic"); - return ESP_GATT_NOT_CONNECTED; - } - - if (enable) { - ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); - } - - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); -} - -esp32_ble_tracker::AdvertisementParserType BluetoothConnection::get_advertisement_parser_type() { - return this->proxy_->get_advertisement_parser_type(); -} - -} // namespace esphome::bluetooth_proxy - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h deleted file mode 100644 index b50ea2d6a2..0000000000 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#ifdef USE_ESP32 - -#include "esphome/components/esp32_ble_client/ble_client_base.h" - -namespace esphome::bluetooth_proxy { - -class BluetoothProxy; - -class BluetoothConnection final : public esp32_ble_client::BLEClientBase { - public: - void dump_config() override; - void loop() override; - bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - - esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); - esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); - - esp_err_t notify_characteristic(uint16_t handle, bool enable); - - esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { - return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); - } - - void set_address(uint64_t address) override; - - protected: - friend class BluetoothProxy; - - bool supports_efficient_uuids_() const; - void send_service_for_discovery_(); - void reset_connection_(esp_err_t reason); - void update_allocated_slot_(uint64_t find_value, uint64_t set_value); - void log_connection_error_(const char *operation, esp_gatt_status_t status); - void log_connection_warning_(const char *operation, esp_err_t err); - void log_gatt_not_connected_(const char *action, const char *type); - void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); - esp_err_t check_and_log_error_(const char *operation, esp_err_t err); - - // Memory optimized layout for 32-bit systems - // Group 1: Pointers (4 bytes each, naturally aligned) - BluetoothProxy *proxy_; - - // Group 2: 2-byte types - int16_t send_service_{-3}; // -3 = INIT_SENDING_SERVICES, -2 = DONE_SENDING_SERVICES, >=0 = service index - - // Group 3: 1-byte types - bool seen_mtu_or_services_{false}; - // 1 byte used, 1 byte padding -}; - -} // namespace esphome::bluetooth_proxy - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index c3461f9c51..878d3cd44e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,14 +1,17 @@ #include "bluetooth_proxy.h" +#ifdef USE_BLUETOOTH_PROXY + +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/macros.h" #include "esphome/core/application.h" #include +#include #include #include -#ifdef USE_ESP32 - namespace esphome::bluetooth_proxy { static const char *const TAG = "bluetooth_proxy"; @@ -22,41 +25,116 @@ static_assert(sizeof(((api::BluetoothLERawAdvertisement *) nullptr)->data) == 62 BluetoothProxy::BluetoothProxy() { global_bluetooth_proxy = this; } -void BluetoothProxy::setup() { - this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; - this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +// The neutral enum's values are the wire values. +static_assert(static_cast(ble_device_base::ScannerState::IDLE) == api::enums::BLUETOOTH_SCANNER_STATE_IDLE); +static_assert(static_cast(ble_device_base::ScannerState::STARTING) == + api::enums::BLUETOOTH_SCANNER_STATE_STARTING); +static_assert(static_cast(ble_device_base::ScannerState::RUNNING) == + api::enums::BLUETOOTH_SCANNER_STATE_RUNNING); +static_assert(static_cast(ble_device_base::ScannerState::FAILED) == + api::enums::BLUETOOTH_SCANNER_STATE_FAILED); +static_assert(static_cast(ble_device_base::ScannerState::STOPPING) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPING); +static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == + api::enums::BLUETOOTH_SCANNER_STATE_STOPPED); - // Capture the configured scan mode from YAML before any API changes - this->configured_scan_active_ = this->parent_->get_scan_active(); - - this->parent_->add_scanner_state_listener(this); -} - -void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } -} - -void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { +bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing owed api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); - resp.mode = this->parent_->get_scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE - : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; + resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE + : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; resp.configured_mode = this->configured_scan_active_ ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE : api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_PASSIVE; - this->api_connection_->send_message(resp); + return this->api_connection_->send_message(resp); } -void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { +#ifdef USE_BLE_SCANNER_STATE_CALLBACK +void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) { + // False only on a refused frame, so the latch arms only when a retry is owed. + this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state); +} +#else +void BluetoothProxy::send_polled_scanner_state_() { + // One read feeds both the frame and the change detector; the detector only + // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a + // full TX buffer) is retried from loop() instead of leaving a stale state. + const bool running = this->hub_->scan_running(); + if (this->send_bluetooth_scanner_state_(running ? ble_device_base::ScannerState::RUNNING + : ble_device_base::ScannerState::IDLE)) { + this->last_scan_running_ = running; + } +} +#endif // USE_BLE_SCANNER_STATE_CALLBACK + +void BluetoothProxy::setup() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; + this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +#endif + + // Capture the configured scan mode from YAML before any API changes + this->configured_scan_active_ = this->hub_->scan_active(); + + this->hub_->set_raw_advertisement_callback({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { + static_cast(self)->on_raw_advertisement_(adv); + }}); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Only push hubs compile the slot; elsewhere loop() polls scan_running(). + this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { + static_cast(self)->send_scanner_state_(state); + }}); +#endif +} + +// The hub delivers raw advertisements on the ESPHome main loop. +void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw) { + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) + return; + + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; + adv.address = raw.address; + adv.rssi = raw.rssi; + adv.address_type = raw.addr_type; + uint8_t length = raw.data_len > sizeof(adv.data) ? sizeof(adv.data) : static_cast(raw.data_len); + adv.data_len = length; + std::memcpy(adv.data, raw.data, length); + + this->response_.advertisements_len++; + + ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + + // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE + if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { + this->flush_pending_advertisements_(); + } +} + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), - connection->address_str(), espbt::client_state_to_string(state)); + connection->address_str(), ble_device_base::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address); +} void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -65,104 +143,176 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, ESP_GATT_NOT_CONNECTED); -} - -#ifdef USE_ESP32_BLE_DEVICE -bool BluetoothProxy::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { - // This method should never be called since bluetooth_proxy always uses raw advertisements - // but we need to provide an implementation to satisfy the virtual method requirement - return false; + if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) { + // No connection, so nothing to latch against; the client's timeout arbitrates. + this->log_reply_dropped_("Not-connected", address); + } } #endif -bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) - return false; - - auto &advertisements = this->response_.advertisements; - - for (size_t i = 0; i < count; i++) { - auto &result = scan_results[i]; - uint8_t length = result.adv_data_len + result.scan_rsp_len; - - // Fill in the data directly at current position - auto &adv = advertisements[this->response_.advertisements_len]; - adv.address = esp32_ble::ble_addr_to_uint64(result.bda); - adv.rssi = result.rssi; - adv.address_type = result.ble_addr_type; - adv.data_len = length; - std::memcpy(adv.data, result.ble_adv, length); - - this->response_.advertisements_len++; - - ESP_LOGV(TAG, "Queuing raw packet from %02X:%02X:%02X:%02X:%02X:%02X, length %d. RSSI: %d dB", result.bda[0], - result.bda[1], result.bda[2], result.bda[3], result.bda[4], result.bda[5], length, result.rssi); - - // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE - if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements_(); - } +void BluetoothProxy::log_advertisement_flush_(bool sent) { + if (sent) { + // VV: one line per flush drowns a verbose log in any busy environment. + ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + } else { + // The rare congestion signal stays at V. + ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); } - - return true; -} - -void BluetoothProxy::log_advertisement_flush_() { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); } void BluetoothProxy::dump_config() { + // Print configured facts. dump_config runs right after setup, before the + // radio is up, so live scan state would always read "stopped" here — the + // loop's BluetoothScannerStateResponse carries the changing value instead. + char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + this->get_bluetooth_mac_address_pretty(mac_str); + const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; + const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" - " Connections: %d", - YESNO(this->active_), this->connection_count_); + " Connections: %d\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + YESNO(this->active_), this->connection_count_, scan_mode, mac_out); +#else + ESP_LOGCONFIG(TAG, + "Bluetooth Proxy:\n" + " Mode: advertisement-only (no GATT connections)\n" + " Configured scan: %s\n" + " Adapter MAC: %s", + scan_mode, mac_out); +#endif } -void BluetoothProxy::loop() { - // Run advertisement flush / connection cleanup every 100ms - uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_advertisement_flush_time_ < 100) - return; - this->last_advertisement_flush_time_ = now; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS - if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { - this->flush_pending_advertisements_(); +void BluetoothProxy::register_connection(BluetoothConnection *connection) { + if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { + // Cannot happen with codegen-sized registration; a silent drop would + // surface later as a null proxy_ dereference, so refuse loudly. + ESP_LOGE(TAG, "Connection registry full, dropping registration"); return; } + // The hub wrapper has no Component lifecycle, so the index is assigned here. + connection->connection_index_ = this->connection_count_; + this->connections_[this->connection_count_++] = connection; + connection->proxy_ = this; +} + +void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } + +void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_value) { + for (auto &slot : this->connections_free_response_.allocated) { + if (slot == find_value) { + slot = set_value; + return; + } + } + // The accounting arrays are only mutated here and sized to the slot count, + // so a miss means the bookkeeping already drifted — say so. + ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); +} + +void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { + // Match before free entry so one address never occupies two pool slots. + PendingReply *free_entry = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); + auto &owed = this->pending_disconnections_[i]; + if (owed.matches(address)) { + owed.set(address, error); + return; + } + if (free_entry == nullptr && owed.empty()) { + free_entry = &owed; + } + } + if (free_entry != nullptr) { + this->log_reply_deferred_("Disconnect", address); + free_entry->set(address, error); + return; + } + // Every entry is owed: evict the first so the newest loss is not silent too. + this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address); + this->pending_disconnections_[0].set(address, error); +} + +void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { + // A reconnect supersedes the owed disconnect; a late resend would shadow + // the new connection. + for (uint8_t i = 0; i < this->connection_count_; i++) { + if (this->pending_disconnections_[i].matches(address)) { + this->pending_disconnections_[i].clear(); + return; // latch_pending_disconnection_ keeps at most one entry per address } } } -esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { - return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; +void BluetoothProxy::answer_device_disconnected_(uint64_t address) { + if (this->send_device_connection(address, false)) { + // A landed answer satisfies any owed notification for the address; a + // drained duplicate would follow it otherwise. + this->clear_pending_disconnection_(address); + return; + } + // Not latched: the client's own request timeout arbitrates, and pooling + // these would let a request retry loop displace an unsolicited disconnect. + this->log_reply_dropped_("Disconnect", address); +} + +void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) { + if (this->send_device_connection(address, false, 0, error)) { + // A later disconnect landing for an address that still has one owed would + // otherwise have the drain repeat it. + this->clear_pending_disconnection_(address); + return; + } + // A dropped disconnect leaves the client believing the link is live, so + // every GATT operation on it times out until something else corrects it. + // latch_pending_disconnection_() reports the leading edge. + this->latch_pending_disconnection_(address, error); +} + +void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { + // The client has no other way to learn of an unsolicited disconnect. + this->send_device_disconnected_(connection->get_address(), reason); + connection->set_address(0); + connection->send_service_ = INIT_SENDING_SERVICES; + this->send_connections_free(); } BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool reserve) { + // Finish the scan before reserving: a free slot earlier in the array must + // not win over a later slot that already holds the address, or one device + // ends up on two slots with a second connection attempt racing the first. + BluetoothConnection *free_slot = nullptr; for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); - if (conn_addr == address) - return connection; - - if (reserve && conn_addr == 0) { - connection->send_service_ = INIT_SENDING_SERVICES; - connection->set_address(address); - // All connections must start at INIT - // We only set the state if we allocate the connection - // to avoid a race where multiple connection attempts - // are made. - connection->set_state(espbt::ClientState::INIT); + if (conn_addr == address) { + // A connect request supersedes an owed disconnect. + if (reserve) { + this->clear_pending_disconnection_(address); + } return connection; } + + if (free_slot == nullptr && conn_addr == 0) + free_slot = connection; } - return nullptr; + if (!reserve || free_slot == nullptr) + return nullptr; + this->clear_pending_disconnection_(address); + free_slot->send_service_ = INIT_SENDING_SERVICES; + free_slot->set_address(address); + // All connections must start at INIT + // We only set the state if we allocate the connection + // to avoid a race where multiple connection attempts + // are made. + free_slot->set_state(ClientState::INIT); + return free_slot; } void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { @@ -172,99 +322,97 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), connection->address_str()); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } - if (connection->state() == espbt::ClientState::CONNECTED || - connection->state() == espbt::ClientState::ESTABLISHED) { + if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); - this->send_device_connection(msg.address, true); + connection->send_connected_reply_(); this->send_connections_free(); return; - } else if (connection->state() == espbt::ClientState::CONNECTING) { - if (connection->disconnect_pending()) { - ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str()); - connection->cancel_pending_disconnect(); - return; - } - this->log_connection_request_ignored_(connection, connection->state()); + } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { + ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", + connection->get_connection_index(), connection->address_str()); return; - } else if (connection->state() != espbt::ClientState::INIT) { + } else if (connection->state() != ClientState::INIT) { + // Covers CONNECTING too: a repeat request during a connect attempt is + // ignored the same way. this->log_connection_request_ignored_(connection, connection->state()); return; } if (msg.request_type == api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE) { - connection->set_connection_type(espbt::ConnectionType::V3_WITH_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITH_CACHE); this->log_connection_info_(connection, "v3 with cache"); } else { // BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE - connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE); + connection->set_connection_type(ble_device_base::ConnectionType::V3_WITHOUT_CACHE); this->log_connection_info_(connection, "v3 without cache"); } - connection->set_remote_addr_type(static_cast(msg.address_type)); - connection->set_state(espbt::ClientState::DISCOVERED); + connection->initiate_connection(static_cast(msg.address_type)); this->send_connections_free(); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); return; } - if (connection->state() != espbt::ClientState::IDLE) { + if (connection->state() != ClientState::IDLE) { connection->disconnect(); } else { connection->set_address(0); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); } break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { + // The connection wrapper exposes the pairing surface; success is + // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { if (!connection->is_paired()) { auto err = connection->pair(); - if (err != ESP_OK) { + if (err != CONN_OK) { this->send_device_pairing(msg.address, false, err); } } else { this->send_device_pairing(msg.address, true); } + } else { + // Answer instead of leaving the client to time out. + this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); } break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_remove_bond_device(address); - this->send_device_pairing(msg.address, ret == ESP_OK, ret); + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + if (ret == CONN_OK) { + // The bond is gone; a live connection must not short-circuit the + // next PAIR as already paired. + auto *connection = this->get_connection_(msg.address, false); + if (connection != nullptr) { + connection->set_unpaired(); + } + } + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - esp_bd_addr_t address; - uint64_to_bd_addr(msg.address, address); - esp_err_t ret = esp_ble_gattc_cache_clean(address); - api::BluetoothDeviceClearCacheResponse call; - call.address = msg.address; - call.success = ret == ESP_OK; - call.error = ret; - - this->api_connection_->send_message(call); - + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { ESP_LOGE(TAG, "V1 connections removed"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); break; } } @@ -278,8 +426,8 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms } auto err = connection->read_characteristic(msg.handle); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -291,8 +439,8 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & } auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -304,8 +452,8 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead } auto err = connection->read_descriptor(msg.handle); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -317,8 +465,8 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri } auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } @@ -328,9 +476,32 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); return; } - if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); - this->send_gatt_services_done(msg.address); + if (!connection->has_gatt_services()) { + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); + // Through the retrying sender: a drop must not leave discovery hanging. + // Re-entry does not depend on the cursor - this branch is gated on + // has_gatt_services() alone, so no restore is needed. + connection->send_services_done_(); + return; + } + if (connection->send_service_ > 0) { + // A request mid-stream restarts from the top so the requester always + // gets the full list. No duplicate risk: the client accumulates batches + // per request, and a same-session re-request only happens after the + // previous request timed out and discarded its partial list. + ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(), + connection->address_str()); + connection->send_service_ = 0; + return; + } + if (connection->send_service_ == SERVICES_DONE_PENDING) { + // A new request supersedes an owed done: the client accumulates batches + // per request, so its fresh, empty accumulator plus a bare done would + // cache as an empty database. The table is freed; the client's timeout + // arbitrates. + ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry", + connection->get_connection_index(), connection->address_str()); + connection->send_service_ = DONE_SENDING_SERVICES; return; } if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet @@ -345,14 +516,16 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest } auto err = connection->notify_characteristic(msg.handle, msg.enable); - if (err != ESP_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + if (err != CONN_OK) { + connection->send_gatt_error_(msg.handle, err); } } void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; + // Not latched (esp32 parity): the request is idempotent, so a drop resolves + // via the client timeout and a retry gives the same answer. Still reported. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -360,10 +533,12 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn if (connection == nullptr || !connection->connected()) { ESP_LOGW(TAG, "[%d] [%s] Cannot set connection params, not connected", - connection ? static_cast(connection->connection_index_) : -1, + connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); - resp.error = ESP_GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); + resp.error = GATT_NOT_CONNECTED; + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } return; } @@ -374,18 +549,207 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn static_cast(std::min(msg.max_interval, max_val)), static_cast(std::min(msg.latency, max_val)), static_cast(std::min(msg.timeout, max_val))); - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } +} + +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#ifdef USE_ESP32 + +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + // esp32 only: BLEHub is the concrete tracker here, so these calls reach + // tracker-native methods beyond the neutral contract. + if (this->hub_->get_scan_active() == active) { + return; + } + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + this->hub_->set_scan_active(active); + this->hub_->stop_scan(); + this->hub_->set_scan_continuous( + true); // Set this to true to automatically start scanning again when it has cleaned up. +} + +#else // !USE_ESP32 + +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->hub_->scan_active() != active) { + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + if (!this->hub_->request_scan_mode(active)) { + // Passive-only controller asked for active scanning; the state report + // below carries the real, unchanged mode so the subscriber does not + // assume the change happened. + ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); + } + } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK + if (this->api_connection_ != nullptr) { + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. + this->send_polled_scanner_state_(); + } +#endif +} + +#endif // USE_ESP32 + +void BluetoothProxy::loop() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Stream pending service-discovery batches every iteration; the streamer + // handles a vanished API connection itself. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->process_pending_services(); + } +#endif + + // Run advertisement flush / scanner-state poll every 100ms + uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_advertisement_flush_time_ < 100) + return; + this->last_advertisement_flush_time_ = now; + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + if (this->connections_free_pending_ && this->api_connection_ != nullptr) { + // Resend a dropped slot-state update, paced by the 100 ms gate so the + // retry does not hammer the congestion it exists to survive. Every build + // sends this at subscribe time (api_connection.cpp), so the drain + // compiles on every proxy build. + this->connections_free_pending_ = false; + this->send_connections_free(this->api_connection_); + } +#endif + + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // The API subscriber is gone: tear down any connections it left behind + // (disconnect() on an already-disconnecting slot is a no-op). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif + return; + } + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Paced retries of owed per-slot notifications; subscriber swaps clear + // stale latches before this runs. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->flush_owed_replies_(); + } + // Address-keyed, not slot-keyed, so it gets its own loop; bounded by + // connection_count_ like the latch and clear helpers. Not pre-cleared: + // the sender clears on success and re-latches on refusal, keeping the + // latch's leading-edge warn honest (same shape as the unpair drain). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.empty()) + continue; + this->send_device_disconnected_(owed.address(), owed.error()); + } + + // An owed unpair reply. Not pre-cleared: the sender clears on success and + // re-latches on refusal, keeping its leading-edge warn guard honest. + if (!this->pending_unpairing_.empty()) { + conn_err_t error = this->pending_unpairing_.error(); + this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error); + } +#endif + +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Resend a dropped scanner-state push (see scanner_state_pending_). + if (this->scanner_state_pending_) { + this->send_scanner_state_(this->hub_->get_scanner_state()); + } +#else + // This hub doesn't push scanner-state transitions; poll and report on + // change. A hub gaining push emits the define and drops this poll. + if (this->hub_->scan_running() != this->last_scan_running_) { + this->send_polled_scanner_state_(); + } +#endif + +#ifdef USE_WIFI + // Wi-Fi (or a coexistence build that can fall back to it): every other + // non-empty 100 ms tick (~200 ms) gives partial batches time to fill + // toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer, + // fuller frames. Full batches still ship immediately from the queueing + // path, and the owed-reply drains above keep the 100 ms cadence. + if (this->response_.advertisements_len != 0) { + if (this->adv_flush_toggle_) { + this->flush_pending_advertisements_(); + } + this->adv_flush_toggle_ = !this->adv_flush_toggle_; + } else { + // Nothing pending (idle, or a full batch just shipped inline): arm so + // the next batch ships on the next tick. + this->adv_flush_toggle_ = true; + } +#else + // No Wi-Fi in the build (ethernet): no airtime worth trading latency for, + // so partial batches flush every tick. + this->flush_pending_advertisements_(); +#endif +} + +void BluetoothProxy::reset_owed_replies_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->connections_free_pending_ = false; +#endif +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() + // re-drives it from the hub, so clearing it there is free. + this->scanner_state_pending_ = false; +#else + // Force a poll-arm mismatch: a frame refused at subscribe time could + // otherwise match the stale detector and never be retried. Inert on + // unsubscribe: loop() returns at the no-subscriber gate before the + // detector runs, and a re-subscribe re-arms this anyway. + this->last_scan_running_ = !this->hub_->scan_running(); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->pending_unpairing_.clear(); + this->pending_disconnections_.fill({}); + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the next + // session; silence (the client's timeout) arbitrates. + auto *connection = this->connections_[i]; + connection->park_service_stream_(); + connection->clear_owed_flags_(); + } +#endif } 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 (api_connection != this->api_connection_) { + if (this->api_connection_ != nullptr) { + // 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)); + } + // Stale retry latches belong to the previous subscriber's session; a + // re-subscribe by the current one keeps what it is still owed. + this->reset_owed_replies_(); } this->api_connection_ = api_connection; - this->parent_->recalculate_advertisement_parser_types(); - - this->send_bluetooth_scanner_state_(this->parent_->get_scanner_state()); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // get_scanner_state() is part of the push-hub surface (see BLEHubContract). + this->send_scanner_state_(this->hub_->get_scanner_state()); +#else + this->send_polled_scanner_state_(); +#endif } void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connection) { @@ -394,19 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; - this->parent_->recalculate_advertisement_parser_types(); + this->reset_owed_replies_(); } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, esp_err_t error) { - if (this->api_connection_ == nullptr) - return; - api::BluetoothDeviceConnectionResponse call; - call.address = address; - call.connected = connected; - call.mtu = mtu; - call.error = error; - this->api_connection_->send_message(call); -} +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -414,28 +769,45 @@ void BluetoothProxy::send_connections_free() { } void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { - api_connection->send_message(this->connections_free_response_); + // Latch only for the current subscriber: loop() resends to api_connection_. + if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) { + // V like the api layer's own buffer-full log: a D would ride the same + // full connection. + ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full"); + this->connections_free_pending_ = true; + } } -void BluetoothProxy::send_gatt_services_done(uint64_t address) { +bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing owed + api::BluetoothDeviceConnectionResponse call; + call.address = address; + call.connected = connected; + call.mtu = mtu; + call.error = error; + return this->api_connection_->send_message(call); +} + +bool BluetoothProxy::send_gatt_services_done(uint64_t address) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTGetServicesDoneResponse call; call.address = address; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error) { +bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTErrorResponse call; call.address = address; call.handle = handle; call.error = error; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } -void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_t error) { +void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { if (this->api_connection_ == nullptr) return; api::BluetoothDevicePairingResponse call; @@ -443,33 +815,63 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, esp_err_ call.paired = paired; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: a retried PAIR is answered from is_paired(), so the client + // recovers on its own. Still worth saying it happened. + this->log_reply_dropped_("Pairing", address); + } } -void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, esp_err_t error) { +void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; + // An owed success is the authoritative answer: a later attempt for the + // same address fails only because the first already removed the bond. + if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && + this->pending_unpairing_.error() == CONN_OK) { + success = true; + error = CONN_OK; + } api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - this->api_connection_->send_message(call); -} - -void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->parent_->get_scan_active() == active) { + if (this->api_connection_->send_message(call)) { + // A later unpair landing for an address that still has one owed would + // otherwise have the drain repeat it. + if (this->pending_unpairing_.matches(address)) { + this->pending_unpairing_.clear(); + } return; } - ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - this->parent_->set_scan_active(active); - this->parent_->stop_scan(); - this->parent_->set_scan_continuous( - true); // Set this to true to automatically start scanning again when it has cleaned up. + if (this->pending_unpairing_.empty()) { + this->log_reply_deferred_("Unpair", address); + } else if (!this->pending_unpairing_.matches(address)) { + this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); } +// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE, +// so its response encoder would be dead weight there. +void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { + if (this->api_connection_ == nullptr) + return; + api::BluetoothDeviceClearCacheResponse call; + call.address = address; + call.success = success; + call.error = error; + + if (!this->api_connection_->send_message(call)) { + // Not latched: clear-cache is idempotent, so a retry gives the same answer. + this->log_reply_dropped_("Clear-cache", address); + } +} +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy -#endif // USE_ESP32 +#endif // USE_BLUETOOTH_PROXY diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 10449f21f1..e233c38b56 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -1,33 +1,38 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" + +#ifdef USE_BLUETOOTH_PROXY #include -#include -#include #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" -#include "esphome/components/esp32_ble_client/ble_client_base.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" -#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" -#include "bluetooth_connection.h" +#include "esphome/components/bluetooth_connection/bluetooth_connection.h" -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID -#include -#endif -#include +#include "esphome/components/ble_device_base/ble_hub_impl.h" + +#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" namespace esphome::bluetooth_proxy { -static constexpr esp_err_t ESP_GATT_NOT_CONNECTED = -1; -static constexpr int DONE_SENDING_SERVICES = -2; -static constexpr int INIT_SENDING_SERVICES = -3; +// The connection-domain types live in the bluetooth_connection component; +// re-exported here so the proxy code reads unqualified. +using bluetooth_connection::CONN_OK; +using bluetooth_connection::conn_err_t; +using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::DONE_SENDING_SERVICES; +using bluetooth_connection::INIT_SENDING_SERVICES; +using bluetooth_connection::SERVICES_DONE_PENDING; -using namespace esp32_ble_client; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +using BluetoothConnection = bluetooth_connection::BluetoothConnection; +using ClientState = ble_device_base::ClientState; +#endif // Legacy versions: // Version 1: Initial version without active connections @@ -36,6 +41,8 @@ using namespace esp32_ble_client; // Version 4: Pairing support // Version 5: Cache clear support static constexpr uint32_t LEGACY_ACTIVE_CONNECTIONS_VERSION = 5; +static constexpr uint32_t LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION = 4; +static constexpr uint32_t LEGACY_ACTIVE_NO_PAIRING_VERSION = 3; static constexpr uint32_t LEGACY_PASSIVE_ONLY_VERSION = 1; enum BluetoothProxyFeature : uint32_t { @@ -53,28 +60,67 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, - public esp32_ble_tracker::BLEScannerStateListener, - public Component { - friend class BluetoothConnection; // Allow connection to update connections_free_response_ +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit +/// error on top. Every error that reaches it fits int16_t. +class PendingReply { + public: + constexpr void set(uint64_t address, conn_err_t error) { + // Mask: the address originates from the client, and a stray high bit + // must not corrupt the reason. + this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); + } + constexpr void clear() { this->word_ = 0; } + // Whole-word test: only (address 0, error 0) reads back as nothing owed. + // A zero-address failure still latches, which is correct - that reply is + // owed too. Neither backend can unpair address 0 successfully. + constexpr bool empty() const { return this->word_ == 0; } + // Masked like set(), so a stray high bit cannot defeat the pool lookups. + constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } + constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; } + constexpr conn_err_t error() const { return static_cast(this->word_ >> 48); } + + private: + static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL; + uint64_t word_{0}; +}; +// Pin the packing at compile time: mask and sign round-trip for every +// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). +constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingReply p; + p.set(address, error); + return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); +} +static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingReply{}.empty()); +#endif + +class BluetoothProxy final : public Component { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Allow the connection to update connections_free_response_ + friend bluetooth_connection::BluetoothConnection; +#endif public: BluetoothProxy(); -#ifdef USE_ESP32_BLE_DEVICE - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; -#endif - bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; + void set_ble_hub(ble_device_base::BLEHub *hub) { this->hub_ = hub; } void dump_config() override; void setup() override; void loop() override; - esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; - void register_connection(BluetoothConnection *connection) { - if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { - this->connections_[this->connection_count_++] = connection; - connection->proxy_ = this; - } - } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + void register_connection(BluetoothConnection *connection); +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS +#ifndef USE_ESP32 + // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below + // snapshots scan_active()/scan_running() and installs the raw callback, and + // the BLEHub contract does not promise those are settled any earlier than + // the hub's own setup(). + float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } +#endif // !USE_ESP32 +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg); @@ -83,63 +129,92 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg); +#endif void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); api::APIConnection *get_api_connection() { return this->api_connection_; } + /// Whether the subscribed API client understands 16/32-bit UUID fields. + bool client_supports_efficient_uuids() const { + return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); + } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, esp_err_t error = ESP_OK); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// False only when a subscriber refused the frame; true = delivered or + /// nobody subscribed. Refusals latch in send_device_disconnected_() and + /// send_connected_reply_(); other callers report via log_reply_dropped_(). + bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); - void send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, esp_err_t error); - void send_device_pairing(uint64_t address, bool paired, esp_err_t error = ESP_OK); - void send_device_unpairing(uint64_t address, bool success, esp_err_t error = ESP_OK); - void send_device_clear_cache(uint64_t address, bool success, esp_err_t error = ESP_OK); + /// Same convention as send_device_connection: false only on a refused frame. + bool send_gatt_services_done(uint64_t address); + /// False only when the API refused the frame, so the reply is still owed. + bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); + /// No default error: the drain rebuilds success as (error == CONN_OK), so a + /// caller that omitted it would have a reported failure resent as a success. + void send_device_unpairing(uint64_t address, bool success, conn_err_t error); + void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); +#endif void bluetooth_scanner_set_mode(bool active); - static void uint64_to_bd_addr(uint64_t address, esp_bd_addr_t bd_addr) { - bd_addr[0] = (address >> 40) & 0xff; - bd_addr[1] = (address >> 32) & 0xff; - bd_addr[2] = (address >> 24) & 0xff; - bd_addr[3] = (address >> 16) & 0xff; - bd_addr[4] = (address >> 8) & 0xff; - bd_addr[5] = (address >> 0) & 0xff; - } - void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } - /// BLEScannerStateListener interface - void on_scanner_state(esp32_ble_tracker::ScannerState state) override; - uint32_t get_legacy_version() const { - if (this->active_) { + if (!this->active_) { + return LEGACY_PASSIVE_ONLY_VERSION; + } + // Legacy clients (which predate the feature flags) map versions to + // capability sets: 5 adds cache clearing, 4 adds pairing, 3 is active + // connections only. + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; } - return LEGACY_PASSIVE_ONLY_VERSION; + return bluetooth_connection::SUPPORTS_PAIRING ? LEGACY_ACTIVE_NO_CACHE_CLEAR_VERSION + : LEGACY_ACTIVE_NO_PAIRING_VERSION; } uint32_t get_feature_flags() const { uint32_t flags = 0; flags |= BluetoothProxyFeature::FEATURE_PASSIVE_SCAN; flags |= BluetoothProxyFeature::FEATURE_RAW_ADVERTISEMENTS; +#ifdef USE_ESP32 flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; +#else + // Advertise mode switching only where the hub honors request_scan_mode(); + // scan_mode_switch is the capability bit for exactly that (#18079) — + // active_scan alone is not enough, a hub may support active scanning yet + // refuse the runtime switch. + if (ble_device_base::BLEHub::get_capabilities().scan_mode_switch) { + flags |= BluetoothProxyFeature::FEATURE_STATE_AND_MODE; + } +#endif if (this->active_) { + // REMOTE_CACHING is mandatory for active connections: API clients + // refuse to connect without it (it selects which V3 connect request + // they send, not device-side caching). flags |= BluetoothProxyFeature::FEATURE_ACTIVE_CONNECTIONS; flags |= BluetoothProxyFeature::FEATURE_REMOTE_CACHING; - flags |= BluetoothProxyFeature::FEATURE_PAIRING; - flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; flags |= BluetoothProxyFeature::FEATURE_CONNECTION_PARAMS_SETTING; + if (bluetooth_connection::SUPPORTS_PAIRING) { + flags |= BluetoothProxyFeature::FEATURE_PAIRING; + } + if (bluetooth_connection::SUPPORTS_CACHE_CLEARING) { + flags |= BluetoothProxyFeature::FEATURE_CACHE_CLEARING; + } } return flags; } - void get_bluetooth_mac_address_pretty(std::span output) { - const uint8_t *mac = esp_bt_dev_get_address(); - if (mac != nullptr) { + void get_bluetooth_mac_address_pretty(std::span output) { + uint8_t mac[MAC_ADDRESS_SIZE] = {}; + this->hub_->get_adapter_mac(mac); + // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn + // the address once the link layer is up, and report all-zero until then. + if (mac_address_is_valid(mac)) { format_mac_addr_upper(mac, output.data()); } else { output[0] = '\0'; @@ -147,51 +222,149 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, } protected: - void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); + bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void send_scanner_state_(ble_device_base::ScannerState state); +#else + void send_polled_scanner_state_(); +#endif + void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); /// Caller must ensure api_connection_ is non-null and API server is connected. void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; - this->api_connection_->send_message(this->response_); + // Perishable and the highest-frequency send here: a drop only reports at + // V, anything louder would be the flood the batch pacing exists to avoid. + [[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); + this->log_advertisement_flush_(sent); #endif this->response_.advertisements_len = 0; } - void log_advertisement_flush_(); + void log_advertisement_flush_(bool sent); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); - void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); + void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); + /// Keep the pre-allocated connections-free message in step when a + /// connection slot changes address (0 = free). Called from the connection + /// classes' set_address(). + void update_address_slot_(uint64_t old_address, uint64_t new_address) { + auto &resp = this->connections_free_response_; + if (new_address == 0 && old_address != 0) { + if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { + resp.free++; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(old_address, 0); + } else if (new_address != 0 && old_address == 0) { + if (resp.free > 0) { + resp.free--; + } else { + this->log_slot_accounting_mismatch_(); + } + this->replace_allocated_slot_(0, new_address); + } + } + void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); + void log_slot_accounting_mismatch_(); + /// Free a connection slot after teardown: notify the API client and reset + /// the streaming cursor. Important: does NOT send send_gatt_services_done() + /// when service streaming was interrupted -- the client (aioesphomeapi) has + /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service + /// discovery and retry, rather than being told a partial list is complete. + void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); + /// Drop any owed freed-slot notification for this address (client reconnected). + void clear_pending_disconnection_(uint64_t address); + /// Send connected=false and pool it for the paced drain if refused. A + /// dropped disconnect desynchronises the proxy: the client keeps a link it + /// believes is live and every operation on it times out. Unsolicited and + /// drained notifications only; request answers use the variant below. + void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK); + /// Answer a request with connected=false. Never pools: a refusal falls back + /// to the client's request timeout, keeping the pool for the unsolicited + /// notifications the client cannot recover on its own. + void answer_device_disconnected_(uint64_t address); + /// Pool a refused freed-slot notification for the paced drain. + void latch_pending_disconnection_(uint64_t address, conn_err_t error); +#endif + + /// Drop everything the ending session was owed. One list, so a new latch is + /// one edit rather than two call sites where an omission looks deliberate. + /// Drops state only, never sends: api_connection_ is the departing + /// subscriber on subscribe and nullptr on unsubscribe. + void reset_owed_replies_(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// Report a reply we deliberately do not latch, so no drop is silent. + void log_reply_dropped_(const char *what, uint64_t address); + /// A latched reply's leading edge; the drain's re-refusals stay quiet. + void log_reply_deferred_(const char *what, uint64_t address); + /// A latched reply lost to a newer one for a different address. + void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); +#endif + // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Group 2: Fixed-size array of connection pointers std::array connections_{}; + // Address-keyed pool of owed freed-slot notifications; loop() resends. + // Proxy-only state, kept off BluetoothConnection; entries are not tied to + // slot indices. + std::array pending_disconnections_{}; + // Owed unpair reply. The bond is already gone when the send is refused, so + // a retry is told the unpair failed when it succeeded. One slot: a second + // refused unpair displaces the first, as happened to both before this. + PendingReply pending_unpairing_{}; +#endif + ble_device_base::BLEHub *hub_{nullptr}; + // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below + // start on an even word, closing two alignment holes. + uint32_t last_advertisement_flush_time_{0}; // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; - // Group 3: 4-byte types - uint32_t last_advertisement_flush_time_{0}; - +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; +#endif // Group 4: 1-byte types grouped together bool active_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // A dropped send (full TCP buffer) would leave the API client with a stale + // slot state forever; the cached response is current by construction, so + // retrying it from loop() is an idempotent resync. + bool connections_free_pending_{false}; uint8_t connection_count_{0}; +#endif bool configured_scan_active_{false}; // Configured scan mode from YAML - // 3 bytes used, 1 byte padding +#ifdef USE_WIFI + /// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial + /// batches fill; an idle tick re-arms, so the first batch after a gap + /// still ships on the next tick. See loop(). + bool adv_flush_toggle_{false}; +#endif +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // A dropped push (full TX buffer) is re-queried from the hub and resent + // from loop(); the hub's current state is idempotent by construction. + bool scanner_state_pending_{false}; +#else + bool last_scan_running_{false}; // Last scanner state reported to the subscriber +#endif }; extern BluetoothProxy *global_bluetooth_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::bluetooth_proxy -#endif // USE_ESP32 +#endif // USE_BLUETOOTH_PROXY 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/bm8563/time.py b/esphome/components/bm8563/time.py index ba264f00bf..5ef162bb7c 100644 --- a/esphome/components/bm8563/time.py +++ b/esphome/components/bm8563/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -35,7 +38,12 @@ CONFIG_SCHEMA = ( ), synchronous=True, ) -async def bm8563_write_time_to_code(config, action_id, template_arg, args): +async def bm8563_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -52,7 +60,12 @@ async def bm8563_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_start_timer_to_code(config, action_id, template_arg, args): +async def bm8563_start_timer_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) @@ -70,13 +83,18 @@ async def bm8563_start_timer_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def bm8563_read_time_to_code(config, action_id, template_arg, args): +async def bm8563_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bme280_base/__init__.py b/esphome/components/bme280_base/__init__.py index c37191bc07..287946801e 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -16,6 +16,8 @@ from esphome.const import ( UNIT_HECTOPASCAL, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -84,7 +86,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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_i2c/sensor.py b/esphome/components/bme280_i2c/sensor.py index 1c37033613..536e8ec794 100644 --- a/esphome/components/bme280_i2c/sensor.py +++ b/esphome/components/bme280_i2c/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv +from esphome.types import ConfigType from ..bme280_base import CONFIG_SCHEMA_BASE, to_code_base @@ -17,6 +18,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend({cv.GenerateID(): cv.declare_id(BME280I2CComponent)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await i2c.register_i2c_device(var, config) 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/bme280_spi/sensor.py b/esphome/components/bme280_spi/sensor.py index 7f4fb5cf44..1d53fe25fa 100644 --- a/esphome/components/bme280_spi/sensor.py +++ b/esphome/components/bme280_spi/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv +from esphome.types import ConfigType from ..bme280_base import CONFIG_SCHEMA_BASE, to_code_base @@ -19,6 +20,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend(spi.spi_device_schema()).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await spi.register_spi_device(var, config) 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/sensor.py b/esphome/components/bme680/sensor.py index f41aefcec3..dce5c88cfa 100644 --- a/esphome/components/bme680/sensor.py +++ b/esphome/components/bme680/sensor.py @@ -22,6 +22,7 @@ from esphome.const import ( UNIT_OHM, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -125,7 +126,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 2365f8d107..35df2a7ea3 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -1,7 +1,9 @@ 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 +from esphome.types import ConfigType CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] @@ -12,7 +14,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") @@ -76,7 +77,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index b7f8c0da77..823f32c446 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() { " IAQ Mode: %s\n" " Supply Voltage: %sV\n" " Sample Rate: %s\n" - " State Save Interval: %ims", + " State Save Interval: %" PRIu32 "ms", this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile", this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8", BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); @@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe } void BME680BSECComponent::delay_ms(uint32_t period) { - ESP_LOGV(TAG, "Delaying for %ums", period); + ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period); delay(period); } 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..153890b57f 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, @@ -24,14 +29,13 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType 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" @@ -108,7 +112,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -118,7 +122,7 @@ async def setup_conf(config, key, hub): ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme680_bsec/text_sensor.py b/esphome/components/bme680_bsec/text_sensor.py index 1fbb9e2aeb..6da1c9d287 100644 --- a/esphome/components/bme680_bsec/text_sensor.py +++ b/esphome/components/bme680_bsec/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME680_BSEC_ID, BME680BSECComponent @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME680_BSEC_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5083d283ef..8208672b6a 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,8 +1,8 @@ -import hashlib 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, @@ -11,6 +11,9 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.cpp_generator import MockObj +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -24,7 +27,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") @@ -74,11 +76,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - 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 + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -97,7 +95,7 @@ def _compute_url(config: dict) -> str: return f"https://raw.githubusercontent.com/boschsensortec/Bosch-BSEC2-Library/{BSEC2_LIBRARY_VERSION}/src/config/{model}/{model}_{algo}_{volts}_{sample_rate}_{operating_age}/{filename}.txt" -def download_bme68x_blob(config): +def download_bme68x_blob(config: ConfigType) -> ConfigType: url = _compute_url(config) path = _compute_local_file_path(url) external_files.download_content(url, path) @@ -105,7 +103,43 @@ def download_bme68x_blob(config): return config -def validate_bme68x(config): +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + +def validate_bme68x(config: ConfigType) -> ConfigType: if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +162,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" @@ -152,7 +179,7 @@ CONFIG_SCHEMA_BASE = ( ) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -169,7 +196,7 @@ async def to_code_base(config): path = _compute_local_file_path(_compute_url(config)) try: - with open(path, encoding="utf-8") as f: + with path.open(encoding="utf-8") as f: bsec2_iaq_config = f.read() except Exception as e: raise core.EsphomeError( diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index f21a9b8138..863cd9d601 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, @@ -24,14 +29,13 @@ from esphome.const import ( UNIT_PARTS_PER_MILLION, UNIT_PERCENT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType 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" @@ -117,7 +121,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await sensor.new_sensor(conf) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) @@ -125,7 +129,7 @@ async def setup_conf(config, key, hub): cg.add(getattr(hub, f"set_{key}_sample_rate")(sample_rate)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme68x_bsec2/text_sensor.py b/esphome/components/bme68x_bsec2/text_sensor.py index fce00afe34..5c6f9f696c 100644 --- a/esphome/components/bme68x_bsec2/text_sensor.py +++ b/esphome/components/bme68x_bsec2/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_IAQ_ACCURACY +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BME68X_BSEC2_ID, BME68xBSEC2Component @@ -21,13 +23,13 @@ CONFIG_SCHEMA = cv.Schema( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if conf := config.get(key): sens = await text_sensor.new_text_sensor(conf) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BME68X_BSEC2_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..1da3bdbd6c 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,11 +1,12 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, to_code_base, ) import esphome.config_validation as cv +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] @@ -13,6 +14,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice @@ -24,6 +30,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend(i2c.i2c_device_schema(0x76)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await i2c.register_i2c_device(var, config) 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/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index cc4037c1ee..4309f0a79f 100644 --- a/esphome/components/bmi160/sensor.py +++ b/esphome/components/bmi160/sensor.py @@ -22,6 +22,7 @@ from esphome.const import ( UNIT_DEGREE_PER_SECOND, UNIT_METER_PER_SECOND_SQUARED, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -82,7 +83,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bmi270/__init__.py b/esphome/components/bmi270/__init__.py new file mode 100644 index 0000000000..0e67e41a0e --- /dev/null +++ b/esphome/components/bmi270/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_BMI270_ID = "bmi270_id" +# C++ namespace / class +bmi270_ns = cg.esphome_ns.namespace("bmi270") +BMI270Component = bmi270_ns.class_("BMI270Component", MotionComponent, i2c.I2CDevice) diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp new file mode 100644 index 0000000000..acb93158d4 --- /dev/null +++ b/esphome/components/bmi270/bmi270.cpp @@ -0,0 +1,209 @@ +#include "bmi270.h" +#include "bmi270_config.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::bmi270 { + +static const char *const TAG = "bmi270"; + +#if defined(USE_ARDUINO) && !defined(USE_ESP32) +static const size_t MAX_I2C_BUFFER_SIZE = 32; +#else +static const size_t MAX_I2C_BUFFER_SIZE = 256; +#endif + +// Configuration blob upload +// The BMI270 requires a firmware config blob to be written to its internal +// memory after every power-on before sensors can be used. + +bool BMI270Component::load_config_file_() { + // 1. Disable advanced power-save so the config port is accessible + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + return false; + delay(1); + + // 2. Prepare config load: write 0x00 to INIT_CTRL to start + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + return false; + + // 3. Burst-write the config in pages + const uint8_t *cfg = BMI270_CONFIG_FILE; + constexpr size_t cfg_len = sizeof(BMI270_CONFIG_FILE); + size_t index = 0; + + while (index != cfg_len) { + // Set the page address in INIT_ADDR registers + uint8_t addr_lsb = (uint8_t) ((index / 2) & 0x0F); + uint8_t addr_msb = (uint8_t) ((index / 2) >> 4); + if (!this->write_byte(BMI270_REG_INIT_ADDR_0, addr_lsb)) + return false; + if (!this->write_byte(BMI270_REG_INIT_ADDR_0 + 1, addr_msb)) + return false; + + // Write a burst of up to the maximum allowed size + size_t burst = clamp_at_most(cfg_len - index, MAX_I2C_BUFFER_SIZE); + if (this->write_register(BMI270_REG_INIT_DATA, cfg + index, burst) != i2c::ERROR_OK) + return false; + + index += burst; + } + + // 4. Signal end of config load + if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + return false; + delay(20); // spec: wait ≥20 ms for init to complete + + // 5. Check INTERNAL_STATUS: bit[0:3] should be 0x01 ("initialisation OK") + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) + return false; + if ((status & 0x0F) != 0x01) { + ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); + return false; + } + return true; +} + +// setup() ─ + +void BMI270Component::setup() { + MotionComponent::setup(); + // 1. Verify chip ID + uint8_t chip_id = 0; + if (!this->read_byte(BMI270_REG_CHIP_ID, &chip_id)) { + ESP_LOGE(TAG, "Failed to read chip ID – check wiring / address"); + this->mark_failed(); + return; + } + if (chip_id != BMI270_CHIP_ID_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", chip_id, BMI270_CHIP_ID_VALUE); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); + + // 2. Soft-reset via CMD register (0x7E = 0xB6) + if (!this->write_byte(0x7E, 0xB6)) { + this->mark_failed(); + return; + } + delay(20); + + // 4. Upload the configuration blob + if (!load_config_file_()) { + ESP_LOGE(TAG, "Config file upload failed"); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Config blob uploaded ✓"); + + // 5. Configure accelerometer + // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) + uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_ACC_RANGE, (uint8_t) accel_range_)) { + this->mark_failed(); + return; + } + + // 6. Configure gyroscope + // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { + this->mark_failed(); + return; + } + if (!this->write_byte(BMI270_REG_GYR_RANGE, (uint8_t) gyro_range_)) { + this->mark_failed(); + return; + } + + // 7. Enable accelerometer, gyroscope, and temperature sensor + // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] + if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + this->mark_failed(); + return; + } + delay(5); + + // 8. Re-enable advanced power save (optional; keeps current low between reads) + // Disabled here for simplicity – leave in performance mode + if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + this->mark_failed(); + return; + } + + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); +} + +void BMI270Component::dump_config() { + ESP_LOGCONFIG(TAG, "BMI270 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[] = {"±2000°/s", "±1000°/s", "±500°/s", "±250°/s", "±125°/s"}; + + ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); + ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); + MotionComponent::dump_config(); +} + +// update() ─ +// Reads all 6 axes + temperature in one block + +bool BMI270Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + uint8_t raw_data[REG_READ_LEN]; + if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + ESP_LOGW(TAG, "Failed to read IMU data"); + return false; + } + // Scale factor: LSB/g depends on range + // raw is a signed 16-bit value; full-scale = range_g * 2^15 lsb + static constexpr float ACCEL_SCALE[] = { + 2.0f / 32768.0f, + 4.0f / 32768.0f, + 8.0f / 32768.0f, + 16.0f / 32768.0f, + }; + float scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = (int16_t) ((raw_data[1] << 8) | raw_data[0]) * scale; + data.acceleration[motion::Y_AXIS] = (int16_t) ((raw_data[3] << 8) | raw_data[2]) * scale; + data.acceleration[motion::Z_AXIS] = (int16_t) ((raw_data[5] << 8) | raw_data[4]) * scale; + + // Gyroscope: registers 0x12–0x17 (6 bytes) + // Scale: full-scale range / 2^15 + static constexpr float GYRO_SCALE[] = { + 2000.0f / 32768.0f, 1000.0f / 32768.0f, 500.0f / 32768.0f, 250.0f / 32768.0f, 125.0f / 32768.0f, + }; + static constexpr uint8_t GYR_OFFS = BMI270_REG_DATA_14 - BMI270_REG_DATA_8; + scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 1] << 8) | raw_data[GYR_OFFS + 0]) * scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; + + if (this->temperature_callback_.empty()) + return true; + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h new file mode 100644 index 0000000000..56d6a60952 --- /dev/null +++ b/esphome/components/bmi270/bmi270.h @@ -0,0 +1,108 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include + +namespace esphome::bmi270 { + +// Register map +static const uint8_t BMI270_REG_CHIP_ID = 0x00; +static const uint8_t BMI270_REG_ERR_REG = 0x02; +static const uint8_t BMI270_REG_STATUS = 0x03; +static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static const uint8_t BMI270_REG_TEMP_0 = 0x22; +static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) + +static constexpr uint8_t REG_READ_LEN = + BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) + +static const uint8_t BMI270_REG_PWR_CONF = 0x7C; +static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static const uint8_t BMI270_REG_INIT_CTRL = 0x59; +static const uint8_t BMI270_REG_INIT_DATA = 0x5E; +static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static const uint8_t BMI270_REG_ACC_CONF = 0x40; +static const uint8_t BMI270_REG_ACC_RANGE = 0x41; +static const uint8_t BMI270_REG_GYR_CONF = 0x42; +static const uint8_t BMI270_REG_GYR_RANGE = 0x43; + +static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Accelerometer range options +enum BMI270AccelRange : uint8_t { + BMI270_ACCEL_RANGE_2G = 0x00, + BMI270_ACCEL_RANGE_4G = 0x01, + BMI270_ACCEL_RANGE_8G = 0x02, + BMI270_ACCEL_RANGE_16G = 0x03, +}; + +// Accelerometer ODR options +enum BMI270AccelODR : uint8_t { + BMI270_ACCEL_ODR_12_5 = 0x05, + BMI270_ACCEL_ODR_25 = 0x06, + BMI270_ACCEL_ODR_50 = 0x07, + BMI270_ACCEL_ODR_100 = 0x08, + BMI270_ACCEL_ODR_200 = 0x09, + BMI270_ACCEL_ODR_400 = 0x0A, + BMI270_ACCEL_ODR_800 = 0x0B, + BMI270_ACCEL_ODR_1600 = 0x0C, +}; + +// Gyroscope range options +enum BMI270GyroRange : uint8_t { + BMI270_GYRO_RANGE_2000 = 0x00, + BMI270_GYRO_RANGE_1000 = 0x01, + BMI270_GYRO_RANGE_500 = 0x02, + BMI270_GYRO_RANGE_250 = 0x03, + BMI270_GYRO_RANGE_125 = 0x04, +}; + +// Gyroscope ODR options +enum BMI270GyroODR : uint8_t { + BMI270_GYRO_ODR_25 = 0x06, + BMI270_GYRO_ODR_50 = 0x07, + BMI270_GYRO_ODR_100 = 0x08, + BMI270_GYRO_ODR_200 = 0x09, + BMI270_GYRO_ODR_400 = 0x0A, + BMI270_GYRO_ODR_800 = 0x0B, + BMI270_GYRO_ODR_1600 = 0x0C, + BMI270_GYRO_ODR_3200 = 0x0D, +}; + +// ---Data class + +// Main component class +class BMI270Component final : 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(BMI270AccelRange r) { this->accel_range_ = r; } + void set_accel_odr(BMI270AccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(BMI270GyroODR 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; + bool load_config_file_(); + + // Config + BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; + BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; + BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; + BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmi270_config.h b/esphome/components/bmi270/bmi270_config.h new file mode 100644 index 0000000000..4243f4e157 --- /dev/null +++ b/esphome/components/bmi270/bmi270_config.h @@ -0,0 +1,483 @@ +#pragma once +#include + +namespace esphome::bmi270 { + +/** + BMI270 configuration file (chip ID 0x24, firmware v2.86.1) + Source: Bosch Sensortec BMI270_SensorAPI (BSD-3-Clause) + https://github.com/boschsensortec/BMI270_SensorAPI + +Copyright (c) 2023 Bosch Sensortec GmbH. All rights reserved. + +BSD-3-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + + + This blob MUST be written to the chip's internal INIT_DATA register + after every power cycle, before any sensor data can be read. + --------------------------------------------------------------------------- */ + +static constexpr uint8_t BMI270_CONFIG_FILE[] = { + 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x3d, 0xb1, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x91, 0x03, 0x80, 0x2e, 0xbc, + 0xb0, 0x80, 0x2e, 0xa3, 0x03, 0xc8, 0x2e, 0x00, 0x2e, 0x80, 0x2e, 0x00, 0xb0, 0x50, 0x30, 0x21, 0x2e, 0x59, 0xf5, + 0x10, 0x30, 0x21, 0x2e, 0x6a, 0xf5, 0x80, 0x2e, 0x3b, 0x03, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x01, 0x00, 0x22, + 0x00, 0x75, 0x00, 0x00, 0x10, 0x00, 0x10, 0xd1, 0x00, 0xb3, 0x43, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0xe0, 0x5f, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x92, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x19, 0x00, 0x00, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, + 0xe0, 0xaa, 0x38, 0x05, 0xe0, 0x90, 0x30, 0xfa, 0x00, 0x96, 0x00, 0x4b, 0x09, 0x11, 0x00, 0x11, 0x00, 0x02, 0x00, + 0x2d, 0x01, 0xd4, 0x7b, 0x3b, 0x01, 0xdb, 0x7a, 0x04, 0x00, 0x3f, 0x7b, 0xcd, 0x6c, 0xc3, 0x04, 0x85, 0x09, 0xc3, + 0x04, 0xec, 0xe6, 0x0c, 0x46, 0x01, 0x00, 0x27, 0x00, 0x19, 0x00, 0x96, 0x00, 0xa0, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0xf0, 0x3c, 0x00, 0x01, 0x01, 0x00, 0x03, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x32, 0x00, 0x05, 0x00, 0xee, + 0x06, 0x04, 0x00, 0xc8, 0x00, 0x00, 0x00, 0x04, 0x00, 0xa8, 0x05, 0xee, 0x06, 0x00, 0x04, 0xbc, 0x02, 0xb3, 0x00, + 0x85, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xb4, 0x00, 0x01, 0x00, 0xb9, 0x00, 0x01, 0x00, 0x98, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x80, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0xde, + 0x00, 0xeb, 0x00, 0xda, 0x00, 0x00, 0x0c, 0xff, 0x0f, 0x00, 0x04, 0xc0, 0x00, 0x5b, 0xf5, 0xc9, 0x01, 0x1e, 0xf2, + 0x80, 0x00, 0x3f, 0xff, 0x19, 0xf4, 0x58, 0xf5, 0x66, 0xf5, 0x64, 0xf5, 0xc0, 0xf1, 0xf0, 0x00, 0xe0, 0x00, 0xcd, + 0x01, 0xd3, 0x01, 0xdb, 0x01, 0xff, 0x7f, 0xff, 0x01, 0xe4, 0x00, 0x74, 0xf7, 0xf3, 0x00, 0xfa, 0x00, 0xff, 0x3f, + 0xca, 0x03, 0x6c, 0x38, 0x56, 0xfe, 0x44, 0xfd, 0xbc, 0x02, 0xf9, 0x06, 0x00, 0xfc, 0x12, 0x02, 0xae, 0x01, 0x58, + 0xfa, 0x9a, 0xfd, 0x77, 0x05, 0xbb, 0x02, 0x96, 0x01, 0x95, 0x01, 0x7f, 0x01, 0x82, 0x01, 0x89, 0x01, 0x87, 0x01, + 0x88, 0x01, 0x8a, 0x01, 0x8c, 0x01, 0x8f, 0x01, 0x8d, 0x01, 0x92, 0x01, 0x91, 0x01, 0xdd, 0x00, 0x9f, 0x01, 0x7e, + 0x01, 0xdb, 0x00, 0xb6, 0x01, 0x70, 0x69, 0x26, 0xd3, 0x9c, 0x07, 0x1f, 0x05, 0x9d, 0x00, 0x00, 0x08, 0xbc, 0x05, + 0x37, 0xfa, 0xa2, 0x01, 0xaa, 0x01, 0xa1, 0x01, 0xa8, 0x01, 0xa0, 0x01, 0xa8, 0x05, 0xb4, 0x01, 0xb4, 0x01, 0xce, + 0x00, 0xd0, 0x00, 0xfc, 0x00, 0xc5, 0x01, 0xff, 0xfb, 0xb1, 0x00, 0x00, 0x38, 0x00, 0x30, 0xfd, 0xf5, 0xfc, 0xf5, + 0xcd, 0x01, 0xa0, 0x00, 0x5f, 0xff, 0x00, 0x40, 0xff, 0x00, 0x00, 0x80, 0x6d, 0x0f, 0xeb, 0x00, 0x7f, 0xff, 0xc2, + 0xf5, 0x68, 0xf7, 0xb3, 0xf1, 0x67, 0x0f, 0x5b, 0x0f, 0x61, 0x0f, 0x80, 0x0f, 0x58, 0xf7, 0x5b, 0xf7, 0x83, 0x0f, + 0x86, 0x00, 0x72, 0x0f, 0x85, 0x0f, 0xc6, 0xf1, 0x7f, 0x0f, 0x6c, 0xf7, 0x00, 0xe0, 0x00, 0xff, 0xd1, 0xf5, 0x87, + 0x0f, 0x8a, 0x0f, 0xff, 0x03, 0xf0, 0x3f, 0x8b, 0x00, 0x8e, 0x00, 0x90, 0x00, 0xb9, 0x00, 0x2d, 0xf5, 0xca, 0xf5, + 0xcb, 0x01, 0x20, 0xf2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x50, 0x98, 0x2e, + 0xd7, 0x0e, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, 0xf0, 0x7f, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x00, + 0x2e, 0x01, 0x80, 0x08, 0xa2, 0xfb, 0x2f, 0x98, 0x2e, 0xba, 0x03, 0x21, 0x2e, 0x19, 0x00, 0x01, 0x2e, 0xee, 0x00, + 0x00, 0xb2, 0x07, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x03, 0x2f, 0x01, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x07, + 0xcc, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x27, 0x2f, 0x05, 0x2e, 0x8a, 0x00, 0x05, 0x52, 0x98, 0x2e, 0xc7, 0xc1, + 0x03, 0x2e, 0xe9, 0x00, 0x40, 0xb2, 0xf0, 0x7f, 0x08, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x00, + 0x30, 0x21, 0x2e, 0xe9, 0x00, 0x98, 0x2e, 0xb4, 0xb1, 0x01, 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x10, 0x2f, 0x05, 0x50, + 0x98, 0x2e, 0x4d, 0xc3, 0x05, 0x50, 0x98, 0x2e, 0x5a, 0xc7, 0x98, 0x2e, 0xf9, 0xb4, 0x98, 0x2e, 0x54, 0xb2, 0x98, + 0x2e, 0x67, 0xb6, 0x98, 0x2e, 0x17, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x2e, 0xef, 0x00, 0x00, 0xb2, + 0x04, 0x2f, 0x98, 0x2e, 0x7a, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xef, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xae, 0x0b, + 0x2f, 0x01, 0x2e, 0xdd, 0x00, 0x00, 0xb2, 0x07, 0x2f, 0x05, 0x52, 0x98, 0x2e, 0x8e, 0x0e, 0x00, 0xb2, 0x02, 0x2f, + 0x10, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x01, 0x2e, 0x7d, 0x00, 0x00, 0x90, 0x90, 0x2e, 0xf1, 0x02, 0x01, 0x2e, 0xd7, + 0x00, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x2f, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7b, 0x00, + 0x00, 0xb2, 0x12, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x00, 0x90, 0x02, 0x2f, 0x98, 0x2e, 0x1f, 0x0e, 0x09, 0x2d, 0x98, + 0x2e, 0x81, 0x0d, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0x90, 0x02, 0x2f, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x00, 0x30, + 0x21, 0x2e, 0x7b, 0x00, 0x01, 0x2e, 0x7c, 0x00, 0x00, 0xb2, 0x90, 0x2e, 0x09, 0x03, 0x01, 0x2e, 0x7c, 0x00, 0x01, + 0x31, 0x01, 0x08, 0x00, 0xb2, 0x04, 0x2f, 0x98, 0x2e, 0x47, 0xcb, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x81, 0x30, + 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, 0xb2, 0x61, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0xd4, 0x00, 0x98, + 0xbc, 0x98, 0xb8, 0x05, 0xb2, 0x0f, 0x58, 0x23, 0x2f, 0x07, 0x90, 0x09, 0x54, 0x00, 0x30, 0x37, 0x2f, 0x15, 0x41, + 0x04, 0x41, 0xdc, 0xbe, 0x44, 0xbe, 0xdc, 0xba, 0x2c, 0x01, 0x61, 0x00, 0x0f, 0x56, 0x4a, 0x0f, 0x0c, 0x2f, 0xd1, + 0x42, 0x94, 0xb8, 0xc1, 0x42, 0x11, 0x30, 0x05, 0x2e, 0x6a, 0xf7, 0x2c, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x08, 0x22, + 0x98, 0x2e, 0xc3, 0xb7, 0x21, 0x2d, 0x61, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x98, 0x2e, 0xc3, 0xb7, 0x00, 0x30, 0x21, + 0x2e, 0x5a, 0xf5, 0x18, 0x2d, 0xe1, 0x7f, 0x50, 0x30, 0x98, 0x2e, 0xfa, 0x03, 0x0f, 0x52, 0x07, 0x50, 0x50, 0x42, + 0x70, 0x30, 0x0d, 0x54, 0x42, 0x42, 0x7e, 0x82, 0xe2, 0x6f, 0x80, 0xb2, 0x42, 0x42, 0x05, 0x2f, 0x21, 0x2e, 0xd4, + 0x00, 0x10, 0x30, 0x98, 0x2e, 0xc3, 0xb7, 0x03, 0x2d, 0x60, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x01, 0x2e, 0xd4, 0x00, + 0x06, 0x90, 0x18, 0x2f, 0x01, 0x2e, 0x76, 0x00, 0x0b, 0x54, 0x07, 0x52, 0xe0, 0x7f, 0x98, 0x2e, 0x7a, 0xc1, 0xe1, + 0x6f, 0x08, 0x1a, 0x40, 0x30, 0x08, 0x2f, 0x21, 0x2e, 0xd4, 0x00, 0x20, 0x30, 0x98, 0x2e, 0xaf, 0xb7, 0x50, 0x32, + 0x98, 0x2e, 0xfa, 0x03, 0x05, 0x2d, 0x98, 0x2e, 0x38, 0x0e, 0x00, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x00, 0x30, 0x21, + 0x2e, 0x7c, 0x00, 0x18, 0x2d, 0x01, 0x2e, 0xd4, 0x00, 0x03, 0xaa, 0x01, 0x2f, 0x98, 0x2e, 0x45, 0x0e, 0x01, 0x2e, + 0xd4, 0x00, 0x3f, 0x80, 0x03, 0xa2, 0x01, 0x2f, 0x00, 0x2e, 0x02, 0x2d, 0x98, 0x2e, 0x5b, 0x0e, 0x30, 0x30, 0x98, + 0x2e, 0xce, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7d, 0x00, 0x50, 0x32, 0x98, 0x2e, 0xfa, 0x03, 0x01, 0x2e, 0x77, 0x00, + 0x00, 0xb2, 0x24, 0x2f, 0x98, 0x2e, 0xf5, 0xcb, 0x03, 0x2e, 0xd5, 0x00, 0x11, 0x54, 0x01, 0x0a, 0xbc, 0x84, 0x83, + 0x86, 0x21, 0x2e, 0xc9, 0x01, 0xe0, 0x40, 0x13, 0x52, 0xc4, 0x40, 0x82, 0x40, 0xa8, 0xb9, 0x52, 0x42, 0x43, 0xbe, + 0x53, 0x42, 0x04, 0x0a, 0x50, 0x42, 0xe1, 0x7f, 0xf0, 0x31, 0x41, 0x40, 0xf2, 0x6f, 0x25, 0xbd, 0x08, 0x08, 0x02, + 0x0a, 0xd0, 0x7f, 0x98, 0x2e, 0xa8, 0xcf, 0x06, 0xbc, 0xd1, 0x6f, 0xe2, 0x6f, 0x08, 0x0a, 0x80, 0x42, 0x98, 0x2e, + 0x58, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x21, 0x2e, 0x77, 0x00, 0x21, 0x2e, 0xdd, 0x00, 0x80, 0x2e, 0xf4, + 0x01, 0x1a, 0x24, 0x22, 0x00, 0x80, 0x2e, 0xec, 0x01, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0xfb, 0x6f, 0x01, 0x30, 0x71, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, 0x2f, 0xc0, 0x2e, 0x01, 0x42, 0xf0, 0x5f, 0x80, + 0x2e, 0x00, 0xc1, 0xfd, 0x2d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9a, 0x01, + 0x34, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x06, 0x32, 0x0f, 0x2e, 0x61, 0xf5, 0xfe, 0x09, 0xc0, 0xb3, 0x04, + 0x2f, 0x17, 0x30, 0x2f, 0x2e, 0xef, 0x00, 0x2d, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, + 0x20, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x46, 0x30, 0x0f, 0x2e, 0xa4, 0xf1, 0xbe, 0x09, 0x80, 0xb3, 0x06, 0x2f, 0x0d, + 0x2e, 0xd4, 0x00, 0x84, 0xaf, 0x02, 0x2f, 0x16, 0x30, 0x2d, 0x2e, 0x7b, 0x00, 0x86, 0x30, 0x2d, 0x2e, 0x60, 0xf5, + 0xf6, 0x6f, 0xe7, 0x6f, 0xe0, 0x5f, 0xc8, 0x2e, 0x01, 0x2e, 0x77, 0xf7, 0x09, 0xbc, 0x0f, 0xb8, 0x00, 0xb2, 0x10, + 0x50, 0xfb, 0x7f, 0x10, 0x30, 0x0b, 0x2f, 0x03, 0x2e, 0x8a, 0x00, 0x96, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x05, 0x2f, + 0x03, 0x2e, 0x68, 0xf7, 0x9e, 0xbc, 0x9f, 0xb8, 0x40, 0xb2, 0x07, 0x2f, 0x03, 0x2e, 0x7e, 0x00, 0x41, 0x90, 0x01, + 0x2f, 0x98, 0x2e, 0xdc, 0x03, 0x03, 0x2c, 0x00, 0x30, 0x21, 0x2e, 0x7e, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, + 0x20, 0x50, 0xe0, 0x7f, 0xfb, 0x7f, 0x00, 0x2e, 0x27, 0x50, 0x98, 0x2e, 0x3b, 0xc8, 0x29, 0x50, 0x98, 0x2e, 0xa7, + 0xc8, 0x01, 0x50, 0x98, 0x2e, 0x55, 0xcc, 0xe1, 0x6f, 0x2b, 0x50, 0x98, 0x2e, 0xe0, 0xc9, 0xfb, 0x6f, 0x00, 0x30, + 0xe0, 0x5f, 0x21, 0x2e, 0x7e, 0x00, 0xb8, 0x2e, 0x73, 0x50, 0x01, 0x30, 0x57, 0x54, 0x11, 0x42, 0x42, 0x0e, 0xfc, + 0x2f, 0xb8, 0x2e, 0x21, 0x2e, 0x59, 0xf5, 0x10, 0x30, 0xc0, 0x2e, 0x21, 0x2e, 0x4a, 0xf1, 0x90, 0x50, 0xf7, 0x7f, + 0xe6, 0x7f, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0xa1, 0x7f, 0x90, 0x7f, 0x82, 0x7f, 0x7b, 0x7f, 0x98, 0x2e, 0x35, + 0xb7, 0x00, 0xb2, 0x90, 0x2e, 0x97, 0xb0, 0x03, 0x2e, 0x8f, 0x00, 0x07, 0x2e, 0x91, 0x00, 0x05, 0x2e, 0xb1, 0x00, + 0x3f, 0xba, 0x9f, 0xb8, 0x01, 0x2e, 0xb1, 0x00, 0xa3, 0xbd, 0x4c, 0x0a, 0x05, 0x2e, 0xb1, 0x00, 0x04, 0xbe, 0xbf, + 0xb9, 0xcb, 0x0a, 0x4f, 0xba, 0x22, 0xbd, 0x01, 0x2e, 0xb3, 0x00, 0xdc, 0x0a, 0x2f, 0xb9, 0x03, 0x2e, 0xb8, 0x00, + 0x0a, 0xbe, 0x9a, 0x0a, 0xcf, 0xb9, 0x9b, 0xbc, 0x01, 0x2e, 0x97, 0x00, 0x9f, 0xb8, 0x93, 0x0a, 0x0f, 0xbc, 0x91, + 0x0a, 0x0f, 0xb8, 0x90, 0x0a, 0x25, 0x2e, 0x18, 0x00, 0x05, 0x2e, 0xc1, 0xf5, 0x2e, 0xbd, 0x2e, 0xb9, 0x01, 0x2e, + 0x19, 0x00, 0x31, 0x30, 0x8a, 0x04, 0x00, 0x90, 0x07, 0x2f, 0x01, 0x2e, 0xd4, 0x00, 0x04, 0xa2, 0x03, 0x2f, 0x01, + 0x2e, 0x18, 0x00, 0x00, 0xb2, 0x0c, 0x2f, 0x19, 0x50, 0x05, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x05, 0x2e, 0x78, 0x00, + 0x80, 0x90, 0x10, 0x30, 0x01, 0x2f, 0x21, 0x2e, 0x78, 0x00, 0x25, 0x2e, 0xdd, 0x00, 0x98, 0x2e, 0x3e, 0xb7, 0x00, + 0xb2, 0x02, 0x30, 0x01, 0x30, 0x04, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x00, 0x2f, 0x21, 0x30, 0x01, 0x2e, + 0xea, 0x00, 0x08, 0x1a, 0x0e, 0x2f, 0x23, 0x2e, 0xea, 0x00, 0x33, 0x30, 0x1b, 0x50, 0x0b, 0x09, 0x01, 0x40, 0x17, + 0x56, 0x46, 0xbe, 0x4b, 0x08, 0x4c, 0x0a, 0x01, 0x42, 0x0a, 0x80, 0x15, 0x52, 0x01, 0x42, 0x00, 0x2e, 0x01, 0x2e, + 0x18, 0x00, 0x00, 0xb2, 0x1f, 0x2f, 0x03, 0x2e, 0xc0, 0xf5, 0xf0, 0x30, 0x48, 0x08, 0x47, 0xaa, 0x74, 0x30, 0x07, + 0x2e, 0x7a, 0x00, 0x61, 0x22, 0x4b, 0x1a, 0x05, 0x2f, 0x07, 0x2e, 0x66, 0xf5, 0xbf, 0xbd, 0xbf, 0xb9, 0xc0, 0x90, + 0x0b, 0x2f, 0x1d, 0x56, 0x2b, 0x30, 0xd2, 0x42, 0xdb, 0x42, 0x01, 0x04, 0xc2, 0x42, 0x04, 0xbd, 0xfe, 0x80, 0x81, + 0x84, 0x23, 0x2e, 0x7a, 0x00, 0x02, 0x42, 0x02, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x05, 0x2e, 0xd6, 0x00, 0x81, 0x84, + 0x25, 0x2e, 0xd6, 0x00, 0x02, 0x31, 0x25, 0x2e, 0x60, 0xf5, 0x05, 0x2e, 0x8a, 0x00, 0x0b, 0x50, 0x90, 0x08, 0x80, + 0xb2, 0x0b, 0x2f, 0x05, 0x2e, 0xca, 0xf5, 0xf0, 0x3e, 0x90, 0x08, 0x25, 0x2e, 0xca, 0xf5, 0x05, 0x2e, 0x59, 0xf5, + 0xe0, 0x3f, 0x90, 0x08, 0x25, 0x2e, 0x59, 0xf5, 0x90, 0x6f, 0xa1, 0x6f, 0xb3, 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0xe6, + 0x6f, 0xf7, 0x6f, 0x7b, 0x6f, 0x82, 0x6f, 0x70, 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0x90, 0x7f, 0xe5, 0x7f, 0xd4, 0x7f, + 0xc3, 0x7f, 0xb1, 0x7f, 0xa2, 0x7f, 0x87, 0x7f, 0xf6, 0x7f, 0x7b, 0x7f, 0x00, 0x2e, 0x01, 0x2e, 0x60, 0xf5, 0x60, + 0x7f, 0x98, 0x2e, 0x35, 0xb7, 0x02, 0x30, 0x63, 0x6f, 0x15, 0x52, 0x50, 0x7f, 0x62, 0x7f, 0x5a, 0x2c, 0x02, 0x32, + 0x1a, 0x09, 0x00, 0xb3, 0x14, 0x2f, 0x00, 0xb2, 0x03, 0x2f, 0x09, 0x2e, 0x18, 0x00, 0x00, 0x91, 0x0c, 0x2f, 0x43, + 0x7f, 0x98, 0x2e, 0x97, 0xb7, 0x1f, 0x50, 0x02, 0x8a, 0x02, 0x32, 0x04, 0x30, 0x25, 0x2e, 0x64, 0xf5, 0x15, 0x52, + 0x50, 0x6f, 0x43, 0x6f, 0x44, 0x43, 0x25, 0x2e, 0x60, 0xf5, 0xd9, 0x08, 0xc0, 0xb2, 0x36, 0x2f, 0x98, 0x2e, 0x3e, + 0xb7, 0x00, 0xb2, 0x06, 0x2f, 0x01, 0x2e, 0x19, 0x00, 0x00, 0xb2, 0x02, 0x2f, 0x50, 0x6f, 0x00, 0x90, 0x0a, 0x2f, + 0x01, 0x2e, 0x79, 0x00, 0x00, 0x90, 0x19, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x79, 0x00, 0x00, 0x30, 0x98, 0x2e, 0xdc, + 0x03, 0x13, 0x2d, 0x01, 0x2e, 0xc3, 0xf5, 0x0c, 0xbc, 0x0f, 0xb8, 0x12, 0x30, 0x10, 0x04, 0x03, 0xb0, 0x26, 0x25, + 0x21, 0x50, 0x03, 0x52, 0x98, 0x2e, 0x4d, 0xb7, 0x10, 0x30, 0x21, 0x2e, 0xee, 0x00, 0x02, 0x30, 0x60, 0x7f, 0x25, + 0x2e, 0x79, 0x00, 0x60, 0x6f, 0x00, 0x90, 0x05, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0xea, 0x00, 0x15, 0x50, 0x21, 0x2e, + 0x64, 0xf5, 0x15, 0x52, 0x23, 0x2e, 0x60, 0xf5, 0x02, 0x32, 0x50, 0x6f, 0x00, 0x90, 0x02, 0x2f, 0x03, 0x30, 0x27, + 0x2e, 0x78, 0x00, 0x07, 0x2e, 0x60, 0xf5, 0x1a, 0x09, 0x00, 0x91, 0xa3, 0x2f, 0x19, 0x09, 0x00, 0x91, 0xa0, 0x2f, + 0x90, 0x6f, 0xa2, 0x6f, 0xb1, 0x6f, 0xc3, 0x6f, 0xd4, 0x6f, 0xe5, 0x6f, 0x7b, 0x6f, 0xf6, 0x6f, 0x87, 0x6f, 0x40, + 0x5f, 0xc8, 0x2e, 0xc0, 0x50, 0xe7, 0x7f, 0xf6, 0x7f, 0x26, 0x30, 0x0f, 0x2e, 0x61, 0xf5, 0x2f, 0x2e, 0x7c, 0x00, + 0x0f, 0x2e, 0x7c, 0x00, 0xbe, 0x09, 0xa2, 0x7f, 0x80, 0x7f, 0x80, 0xb3, 0xd5, 0x7f, 0xc4, 0x7f, 0xb3, 0x7f, 0x91, + 0x7f, 0x7b, 0x7f, 0x0b, 0x2f, 0x23, 0x50, 0x1a, 0x25, 0x12, 0x40, 0x42, 0x7f, 0x74, 0x82, 0x12, 0x40, 0x52, 0x7f, + 0x00, 0x2e, 0x00, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0x6a, 0xd6, 0x81, 0x30, 0x01, 0x2e, 0x7c, 0x00, 0x01, 0x08, 0x00, + 0xb2, 0x42, 0x2f, 0x03, 0x2e, 0x89, 0x00, 0x01, 0x2e, 0x89, 0x00, 0x97, 0xbc, 0x06, 0xbc, 0x9f, 0xb8, 0x0f, 0xb8, + 0x00, 0x90, 0x23, 0x2e, 0xd8, 0x00, 0x10, 0x30, 0x01, 0x30, 0x2a, 0x2f, 0x03, 0x2e, 0xd4, 0x00, 0x44, 0xb2, 0x05, + 0x2f, 0x47, 0xb2, 0x00, 0x30, 0x2d, 0x2f, 0x21, 0x2e, 0x7c, 0x00, 0x2b, 0x2d, 0x03, 0x2e, 0xfd, 0xf5, 0x9e, 0xbc, + 0x9f, 0xb8, 0x40, 0x90, 0x14, 0x2f, 0x03, 0x2e, 0xfc, 0xf5, 0x99, 0xbc, 0x9f, 0xb8, 0x40, 0x90, 0x0e, 0x2f, 0x03, + 0x2e, 0x49, 0xf1, 0x25, 0x54, 0x4a, 0x08, 0x40, 0x90, 0x08, 0x2f, 0x98, 0x2e, 0x35, 0xb7, 0x00, 0xb2, 0x10, 0x30, + 0x03, 0x2f, 0x50, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x10, 0x2d, 0x98, 0x2e, 0xaf, 0xb7, 0x00, 0x30, 0x21, 0x2e, 0x7c, + 0x00, 0x0a, 0x2d, 0x05, 0x2e, 0x69, 0xf7, 0x2d, 0xbd, 0x2f, 0xb9, 0x80, 0xb2, 0x01, 0x2f, 0x21, 0x2e, 0x7d, 0x00, + 0x23, 0x2e, 0x7c, 0x00, 0xe0, 0x31, 0x21, 0x2e, 0x61, 0xf5, 0xf6, 0x6f, 0xe7, 0x6f, 0x80, 0x6f, 0xa2, 0x6f, 0xb3, + 0x6f, 0xc4, 0x6f, 0xd5, 0x6f, 0x7b, 0x6f, 0x91, 0x6f, 0x40, 0x5f, 0xc8, 0x2e, 0x60, 0x51, 0x0a, 0x25, 0x36, 0x88, + 0xf4, 0x7f, 0xeb, 0x7f, 0x00, 0x32, 0x31, 0x52, 0x32, 0x30, 0x13, 0x30, 0x98, 0x2e, 0x15, 0xcb, 0x0a, 0x25, 0x33, + 0x84, 0xd2, 0x7f, 0x43, 0x30, 0x05, 0x50, 0x2d, 0x52, 0x98, 0x2e, 0x95, 0xc1, 0xd2, 0x6f, 0x27, 0x52, 0x98, 0x2e, + 0xd7, 0xc7, 0x2a, 0x25, 0xb0, 0x86, 0xc0, 0x7f, 0xd3, 0x7f, 0xaf, 0x84, 0x29, 0x50, 0xf1, 0x6f, 0x98, 0x2e, 0x4d, + 0xc8, 0x2a, 0x25, 0xae, 0x8a, 0xaa, 0x88, 0xf2, 0x6e, 0x2b, 0x50, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x7f, 0x98, 0x2e, + 0xb6, 0xc8, 0xe0, 0x6e, 0x00, 0xb2, 0x32, 0x2f, 0x33, 0x54, 0x83, 0x86, 0xf1, 0x6f, 0xc3, 0x7f, 0x04, 0x30, 0x30, + 0x30, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0xe3, 0x30, 0xc5, 0x6f, 0x56, 0x40, 0x45, 0x41, 0x28, 0x08, 0x03, 0x14, + 0x0e, 0xb4, 0x08, 0xbc, 0x82, 0x40, 0x10, 0x0a, 0x2f, 0x54, 0x26, 0x05, 0x91, 0x7f, 0x44, 0x28, 0xa3, 0x7f, 0x98, + 0x2e, 0xd9, 0xc0, 0x08, 0xb9, 0x33, 0x30, 0x53, 0x09, 0xc1, 0x6f, 0xd3, 0x6f, 0xf4, 0x6f, 0x83, 0x17, 0x47, 0x40, + 0x6c, 0x15, 0xb2, 0x6f, 0xbe, 0x09, 0x75, 0x0b, 0x90, 0x42, 0x45, 0x42, 0x51, 0x0e, 0x32, 0xbc, 0x02, 0x89, 0xa1, + 0x6f, 0x7e, 0x86, 0xf4, 0x7f, 0xd0, 0x7f, 0xb2, 0x7f, 0x04, 0x30, 0x91, 0x6f, 0xd6, 0x2f, 0xeb, 0x6f, 0xa0, 0x5e, + 0xb8, 0x2e, 0x03, 0x2e, 0x97, 0x00, 0x1b, 0xbc, 0x60, 0x50, 0x9f, 0xbc, 0x0c, 0xb8, 0xf0, 0x7f, 0x40, 0xb2, 0xeb, + 0x7f, 0x2b, 0x2f, 0x03, 0x2e, 0x7f, 0x00, 0x41, 0x40, 0x01, 0x2e, 0xc8, 0x00, 0x01, 0x1a, 0x11, 0x2f, 0x37, 0x58, + 0x23, 0x2e, 0xc8, 0x00, 0x10, 0x41, 0xa0, 0x7f, 0x38, 0x81, 0x01, 0x41, 0xd0, 0x7f, 0xb1, 0x7f, 0x98, 0x2e, 0x64, + 0xcf, 0xd0, 0x6f, 0x07, 0x80, 0xa1, 0x6f, 0x11, 0x42, 0x00, 0x2e, 0xb1, 0x6f, 0x01, 0x42, 0x11, 0x30, 0x01, 0x2e, + 0xfc, 0x00, 0x00, 0xa8, 0x03, 0x30, 0xcb, 0x22, 0x4a, 0x25, 0x01, 0x2e, 0x7f, 0x00, 0x3c, 0x89, 0x35, 0x52, 0x05, + 0x54, 0x98, 0x2e, 0xc4, 0xce, 0xc1, 0x6f, 0xf0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x04, 0x2d, 0x01, 0x30, 0xf0, 0x6f, + 0x98, 0x2e, 0x95, 0xcf, 0xeb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, 0x03, 0x2e, 0xb3, 0x00, 0x02, 0x32, 0xf0, 0x30, 0x03, + 0x31, 0x30, 0x50, 0x8a, 0x08, 0x08, 0x08, 0xcb, 0x08, 0xe0, 0x7f, 0x80, 0xb2, 0xf3, 0x7f, 0xdb, 0x7f, 0x25, 0x2f, + 0x03, 0x2e, 0xca, 0x00, 0x41, 0x90, 0x04, 0x2f, 0x01, 0x30, 0x23, 0x2e, 0xca, 0x00, 0x98, 0x2e, 0x3f, 0x03, 0xc0, + 0xb2, 0x05, 0x2f, 0x03, 0x2e, 0xda, 0x00, 0x00, 0x30, 0x41, 0x04, 0x23, 0x2e, 0xda, 0x00, 0x98, 0x2e, 0x92, 0xb2, + 0x10, 0x25, 0xf0, 0x6f, 0x00, 0xb2, 0x05, 0x2f, 0x01, 0x2e, 0xda, 0x00, 0x02, 0x30, 0x10, 0x04, 0x21, 0x2e, 0xda, + 0x00, 0x40, 0xb2, 0x01, 0x2f, 0x23, 0x2e, 0xc8, 0x01, 0xdb, 0x6f, 0xe0, 0x6f, 0xd0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, + 0x01, 0x30, 0xe0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0x11, 0x30, 0x23, 0x2e, 0xca, 0x00, 0xdb, 0x6f, 0xd0, 0x5f, 0xb8, + 0x2e, 0xd0, 0x50, 0x0a, 0x25, 0x33, 0x84, 0x55, 0x50, 0xd2, 0x7f, 0xe2, 0x7f, 0x03, 0x8c, 0xc0, 0x7f, 0xbb, 0x7f, + 0x00, 0x30, 0x05, 0x5a, 0x39, 0x54, 0x51, 0x41, 0xa5, 0x7f, 0x96, 0x7f, 0x80, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0x05, + 0x30, 0xf5, 0x7f, 0x20, 0x25, 0x91, 0x6f, 0x3b, 0x58, 0x3d, 0x5c, 0x3b, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xc1, 0x6f, + 0xd5, 0x6f, 0x52, 0x40, 0x50, 0x43, 0xc1, 0x7f, 0xd5, 0x7f, 0x10, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, + 0x2e, 0x74, 0xc0, 0x86, 0x6f, 0x30, 0x28, 0x92, 0x6f, 0x82, 0x8c, 0xa5, 0x6f, 0x6f, 0x52, 0x69, 0x0e, 0x39, 0x54, + 0xdb, 0x2f, 0x19, 0xa0, 0x15, 0x30, 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x81, 0x01, 0x0a, 0x2d, 0x01, 0x2e, 0x81, + 0x01, 0x05, 0x28, 0x42, 0x36, 0x21, 0x2e, 0x81, 0x01, 0x02, 0x0e, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x57, 0x50, + 0x12, 0x30, 0x01, 0x40, 0x98, 0x2e, 0xfe, 0xc9, 0x51, 0x6f, 0x0b, 0x5c, 0x8e, 0x0e, 0x3b, 0x6f, 0x57, 0x58, 0x02, + 0x30, 0x21, 0x2e, 0x95, 0x01, 0x45, 0x6f, 0x2a, 0x8d, 0xd2, 0x7f, 0xcb, 0x7f, 0x13, 0x2f, 0x02, 0x30, 0x3f, 0x50, + 0xd2, 0x7f, 0xa8, 0x0e, 0x0e, 0x2f, 0xc0, 0x6f, 0x53, 0x54, 0x02, 0x00, 0x51, 0x54, 0x42, 0x0e, 0x10, 0x30, 0x59, + 0x52, 0x02, 0x30, 0x01, 0x2f, 0x00, 0x2e, 0x03, 0x2d, 0x50, 0x42, 0x42, 0x42, 0x12, 0x30, 0xd2, 0x7f, 0x80, 0xb2, + 0x03, 0x2f, 0x00, 0x30, 0x21, 0x2e, 0x80, 0x01, 0x12, 0x2d, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x80, 0x05, 0x2e, 0x80, + 0x01, 0x11, 0x30, 0x91, 0x28, 0x00, 0x40, 0x25, 0x2e, 0x80, 0x01, 0x10, 0x0e, 0x05, 0x2f, 0x01, 0x2e, 0x7f, 0x01, + 0x01, 0x90, 0x01, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0x00, 0x2e, 0xa0, 0x41, 0x01, 0x90, 0xa6, 0x7f, 0x90, 0x2e, 0xe3, + 0xb4, 0x01, 0x2e, 0x95, 0x01, 0x00, 0xa8, 0x90, 0x2e, 0xe3, 0xb4, 0x5b, 0x54, 0x95, 0x80, 0x82, 0x40, 0x80, 0xb2, + 0x02, 0x40, 0x2d, 0x8c, 0x3f, 0x52, 0x96, 0x7f, 0x90, 0x2e, 0xc2, 0xb3, 0x29, 0x0e, 0x76, 0x2f, 0x01, 0x2e, 0xc9, + 0x00, 0x00, 0x40, 0x81, 0x28, 0x45, 0x52, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, 0x5d, 0x54, 0x80, 0x7f, 0x00, 0x2e, + 0xa1, 0x40, 0x72, 0x7f, 0x82, 0x80, 0x82, 0x40, 0x60, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, + 0xc0, 0x62, 0x6f, 0x05, 0x30, 0x87, 0x40, 0xc0, 0x91, 0x04, 0x30, 0x05, 0x2f, 0x05, 0x2e, 0x83, 0x01, 0x80, 0xb2, + 0x14, 0x30, 0x00, 0x2f, 0x04, 0x30, 0x05, 0x2e, 0xc9, 0x00, 0x73, 0x6f, 0x81, 0x40, 0xe2, 0x40, 0x69, 0x04, 0x11, + 0x0f, 0xe1, 0x40, 0x16, 0x30, 0xfe, 0x29, 0xcb, 0x40, 0x02, 0x2f, 0x83, 0x6f, 0x83, 0x0f, 0x22, 0x2f, 0x47, 0x56, + 0x13, 0x0f, 0x12, 0x30, 0x77, 0x2f, 0x49, 0x54, 0x42, 0x0e, 0x12, 0x30, 0x73, 0x2f, 0x00, 0x91, 0x0a, 0x2f, 0x01, + 0x2e, 0x8b, 0x01, 0x19, 0xa8, 0x02, 0x30, 0x6c, 0x2f, 0x63, 0x50, 0x00, 0x2e, 0x17, 0x42, 0x05, 0x42, 0x68, 0x2c, + 0x12, 0x30, 0x0b, 0x25, 0x08, 0x0f, 0x50, 0x30, 0x02, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x03, 0x2d, 0x40, 0x30, 0x21, + 0x2e, 0x83, 0x01, 0x2b, 0x2e, 0x85, 0x01, 0x5a, 0x2c, 0x12, 0x30, 0x00, 0x91, 0x2b, 0x25, 0x04, 0x2f, 0x63, 0x50, + 0x02, 0x30, 0x17, 0x42, 0x17, 0x2c, 0x02, 0x42, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, 0x74, 0xc0, 0x05, + 0x2e, 0xc9, 0x00, 0x81, 0x84, 0x5b, 0x30, 0x82, 0x40, 0x37, 0x2e, 0x83, 0x01, 0x02, 0x0e, 0x07, 0x2f, 0x5f, 0x52, + 0x40, 0x30, 0x62, 0x40, 0x41, 0x40, 0x91, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x83, 0x01, 0x05, 0x30, 0x2b, 0x2e, 0x85, + 0x01, 0x12, 0x30, 0x36, 0x2c, 0x16, 0x30, 0x15, 0x25, 0x81, 0x7f, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0x98, 0x2e, + 0x74, 0xc0, 0x19, 0xa2, 0x16, 0x30, 0x15, 0x2f, 0x05, 0x2e, 0x97, 0x01, 0x80, 0x6f, 0x82, 0x0e, 0x05, 0x2f, 0x01, + 0x2e, 0x86, 0x01, 0x06, 0x28, 0x21, 0x2e, 0x86, 0x01, 0x0b, 0x2d, 0x03, 0x2e, 0x87, 0x01, 0x5f, 0x54, 0x4e, 0x28, + 0x91, 0x42, 0x00, 0x2e, 0x82, 0x40, 0x90, 0x0e, 0x01, 0x2f, 0x21, 0x2e, 0x88, 0x01, 0x02, 0x30, 0x13, 0x2c, 0x05, + 0x30, 0xc0, 0x6f, 0x08, 0x1c, 0xa8, 0x0f, 0x16, 0x30, 0x05, 0x30, 0x5b, 0x50, 0x09, 0x2f, 0x02, 0x80, 0x2d, 0x2e, + 0x82, 0x01, 0x05, 0x42, 0x05, 0x80, 0x00, 0x2e, 0x02, 0x42, 0x3e, 0x80, 0x00, 0x2e, 0x06, 0x42, 0x02, 0x30, 0x90, + 0x6f, 0x3e, 0x88, 0x01, 0x40, 0x04, 0x41, 0x4c, 0x28, 0x01, 0x42, 0x07, 0x80, 0x10, 0x25, 0x24, 0x40, 0x00, 0x40, + 0x00, 0xa8, 0xf5, 0x22, 0x23, 0x29, 0x44, 0x42, 0x7a, 0x82, 0x7e, 0x88, 0x43, 0x40, 0x04, 0x41, 0x00, 0xab, 0xf5, + 0x23, 0xdf, 0x28, 0x43, 0x42, 0xd9, 0xa0, 0x14, 0x2f, 0x00, 0x90, 0x02, 0x2f, 0xd2, 0x6f, 0x81, 0xb2, 0x05, 0x2f, + 0x63, 0x54, 0x06, 0x28, 0x90, 0x42, 0x85, 0x42, 0x09, 0x2c, 0x02, 0x30, 0x5b, 0x50, 0x03, 0x80, 0x29, 0x2e, 0x7e, + 0x01, 0x2b, 0x2e, 0x82, 0x01, 0x05, 0x42, 0x12, 0x30, 0x2b, 0x2e, 0x83, 0x01, 0x45, 0x82, 0x00, 0x2e, 0x40, 0x40, + 0x7a, 0x82, 0x02, 0xa0, 0x08, 0x2f, 0x63, 0x50, 0x3b, 0x30, 0x15, 0x42, 0x05, 0x42, 0x37, 0x80, 0x37, 0x2e, 0x7e, + 0x01, 0x05, 0x42, 0x12, 0x30, 0x01, 0x2e, 0xc9, 0x00, 0x02, 0x8c, 0x40, 0x40, 0x84, 0x41, 0x7a, 0x8c, 0x04, 0x0f, + 0x03, 0x2f, 0x01, 0x2e, 0x8b, 0x01, 0x19, 0xa4, 0x04, 0x2f, 0x2b, 0x2e, 0x82, 0x01, 0x98, 0x2e, 0xf3, 0x03, 0x12, + 0x30, 0x81, 0x90, 0x61, 0x52, 0x08, 0x2f, 0x65, 0x42, 0x65, 0x42, 0x43, 0x80, 0x39, 0x84, 0x82, 0x88, 0x05, 0x42, + 0x45, 0x42, 0x85, 0x42, 0x05, 0x43, 0x00, 0x2e, 0x80, 0x41, 0x00, 0x90, 0x90, 0x2e, 0xe1, 0xb4, 0x65, 0x54, 0xc1, + 0x6f, 0x80, 0x40, 0x00, 0xb2, 0x43, 0x58, 0x69, 0x50, 0x44, 0x2f, 0x55, 0x5c, 0xb7, 0x87, 0x8c, 0x0f, 0x0d, 0x2e, + 0x96, 0x01, 0xc4, 0x40, 0x36, 0x2f, 0x41, 0x56, 0x8b, 0x0e, 0x2a, 0x2f, 0x0b, 0x52, 0xa1, 0x0e, 0x0a, 0x2f, 0x05, + 0x2e, 0x8f, 0x01, 0x14, 0x25, 0x98, 0x2e, 0xfe, 0xc9, 0x4b, 0x54, 0x02, 0x0f, 0x69, 0x50, 0x05, 0x30, 0x65, 0x54, + 0x15, 0x2f, 0x03, 0x2e, 0x8e, 0x01, 0x4d, 0x5c, 0x8e, 0x0f, 0x3a, 0x2f, 0x05, 0x2e, 0x8f, 0x01, 0x98, 0x2e, 0xfe, + 0xc9, 0x4f, 0x54, 0x82, 0x0f, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x30, 0x2f, 0x6d, 0x52, 0x15, 0x30, 0x42, 0x8c, + 0x45, 0x42, 0x04, 0x30, 0x2b, 0x2c, 0x84, 0x43, 0x6b, 0x52, 0x42, 0x8c, 0x00, 0x2e, 0x85, 0x43, 0x15, 0x30, 0x24, + 0x2c, 0x45, 0x42, 0x8e, 0x0f, 0x20, 0x2f, 0x0d, 0x2e, 0x8e, 0x01, 0xb1, 0x0e, 0x1c, 0x2f, 0x23, 0x2e, 0x8e, 0x01, + 0x1a, 0x2d, 0x0e, 0x0e, 0x17, 0x2f, 0xa1, 0x0f, 0x15, 0x2f, 0x23, 0x2e, 0x8d, 0x01, 0x13, 0x2d, 0x98, 0x2e, 0x74, + 0xc0, 0x43, 0x54, 0xc2, 0x0e, 0x0a, 0x2f, 0x65, 0x50, 0x04, 0x80, 0x0b, 0x30, 0x06, 0x82, 0x0b, 0x42, 0x79, 0x80, + 0x41, 0x40, 0x12, 0x30, 0x25, 0x2e, 0x8c, 0x01, 0x01, 0x42, 0x05, 0x30, 0x69, 0x50, 0x65, 0x54, 0x84, 0x82, 0x43, + 0x84, 0xbe, 0x8c, 0x84, 0x40, 0x86, 0x41, 0x26, 0x29, 0x94, 0x42, 0xbe, 0x8e, 0xd5, 0x7f, 0x19, 0xa1, 0x43, 0x40, + 0x0b, 0x2e, 0x8c, 0x01, 0x84, 0x40, 0xc7, 0x41, 0x5d, 0x29, 0x27, 0x29, 0x45, 0x42, 0x84, 0x42, 0xc2, 0x7f, 0x01, + 0x2f, 0xc0, 0xb3, 0x1d, 0x2f, 0x05, 0x2e, 0x94, 0x01, 0x99, 0xa0, 0x01, 0x2f, 0x80, 0xb3, 0x13, 0x2f, 0x80, 0xb3, + 0x18, 0x2f, 0xc0, 0xb3, 0x16, 0x2f, 0x12, 0x40, 0x01, 0x40, 0x92, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x92, 0x6f, 0x10, + 0x0f, 0x20, 0x30, 0x03, 0x2f, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x0a, 0x2d, 0x21, 0x2e, 0x7e, 0x01, 0x07, 0x2d, + 0x20, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0x03, 0x2d, 0x10, 0x30, 0x21, 0x2e, 0x7e, 0x01, 0xc2, 0x6f, 0x01, 0x2e, 0xc9, + 0x00, 0xbc, 0x84, 0x02, 0x80, 0x82, 0x40, 0x00, 0x40, 0x90, 0x0e, 0xd5, 0x6f, 0x02, 0x2f, 0x15, 0x30, 0x98, 0x2e, + 0xf3, 0x03, 0x41, 0x91, 0x05, 0x30, 0x07, 0x2f, 0x67, 0x50, 0x3d, 0x80, 0x2b, 0x2e, 0x8f, 0x01, 0x05, 0x42, 0x04, + 0x80, 0x00, 0x2e, 0x05, 0x42, 0x02, 0x2c, 0x00, 0x30, 0x00, 0x30, 0xa2, 0x6f, 0x98, 0x8a, 0x86, 0x40, 0x80, 0xa7, + 0x05, 0x2f, 0x98, 0x2e, 0xf3, 0x03, 0xc0, 0x30, 0x21, 0x2e, 0x95, 0x01, 0x06, 0x25, 0x1a, 0x25, 0xe2, 0x6f, 0x76, + 0x82, 0x96, 0x40, 0x56, 0x43, 0x51, 0x0e, 0xfb, 0x2f, 0xbb, 0x6f, 0x30, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xb8, 0x00, + 0x01, 0x31, 0x41, 0x08, 0x40, 0xb2, 0x20, 0x50, 0xf2, 0x30, 0x02, 0x08, 0xfb, 0x7f, 0x01, 0x30, 0x10, 0x2f, 0x05, + 0x2e, 0xcc, 0x00, 0x81, 0x90, 0xe0, 0x7f, 0x03, 0x2f, 0x23, 0x2e, 0xcc, 0x00, 0x98, 0x2e, 0x55, 0xb6, 0x98, 0x2e, + 0x1d, 0xb5, 0x10, 0x25, 0xfb, 0x6f, 0xe0, 0x6f, 0xe0, 0x5f, 0x80, 0x2e, 0x95, 0xcf, 0x98, 0x2e, 0x95, 0xcf, 0x10, + 0x30, 0x21, 0x2e, 0xcc, 0x00, 0xfb, 0x6f, 0xe0, 0x5f, 0xb8, 0x2e, 0x00, 0x51, 0x05, 0x58, 0xeb, 0x7f, 0x2a, 0x25, + 0x89, 0x52, 0x6f, 0x5a, 0x89, 0x50, 0x13, 0x41, 0x06, 0x40, 0xb3, 0x01, 0x16, 0x42, 0xcb, 0x16, 0x06, 0x40, 0xf3, + 0x02, 0x13, 0x42, 0x65, 0x0e, 0xf5, 0x2f, 0x05, 0x40, 0x14, 0x30, 0x2c, 0x29, 0x04, 0x42, 0x08, 0xa1, 0x00, 0x30, + 0x90, 0x2e, 0x52, 0xb6, 0xb3, 0x88, 0xb0, 0x8a, 0xb6, 0x84, 0xa4, 0x7f, 0xc4, 0x7f, 0xb5, 0x7f, 0xd5, 0x7f, 0x92, + 0x7f, 0x73, 0x30, 0x04, 0x30, 0x55, 0x40, 0x42, 0x40, 0x8a, 0x17, 0xf3, 0x08, 0x6b, 0x01, 0x90, 0x02, 0x53, 0xb8, + 0x4b, 0x82, 0xad, 0xbe, 0x71, 0x7f, 0x45, 0x0a, 0x09, 0x54, 0x84, 0x7f, 0x98, 0x2e, 0xd9, 0xc0, 0xa3, 0x6f, 0x7b, + 0x54, 0xd0, 0x42, 0xa3, 0x7f, 0xf2, 0x7f, 0x60, 0x7f, 0x20, 0x25, 0x71, 0x6f, 0x75, 0x5a, 0x77, 0x58, 0x79, 0x5c, + 0x75, 0x56, 0x98, 0x2e, 0x67, 0xcc, 0xb1, 0x6f, 0x62, 0x6f, 0x50, 0x42, 0xb1, 0x7f, 0xb3, 0x30, 0x10, 0x25, 0x98, + 0x2e, 0x0f, 0xca, 0x84, 0x6f, 0x20, 0x29, 0x71, 0x6f, 0x92, 0x6f, 0xa5, 0x6f, 0x76, 0x82, 0x6a, 0x0e, 0x73, 0x30, + 0x00, 0x30, 0xd0, 0x2f, 0xd2, 0x6f, 0xd1, 0x7f, 0xb4, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x02, + 0x0a, 0xc2, 0x6f, 0xc0, 0x7f, 0x98, 0x2e, 0x2b, 0xb7, 0x15, 0xbd, 0x0b, 0xb8, 0x42, 0x0a, 0xc0, 0x6f, 0x08, 0x17, + 0x41, 0x18, 0x89, 0x16, 0xe1, 0x18, 0xd0, 0x18, 0xa1, 0x7f, 0x27, 0x25, 0x16, 0x25, 0x98, 0x2e, 0x79, 0xc0, 0x8b, + 0x54, 0x90, 0x7f, 0xb3, 0x30, 0x82, 0x40, 0x80, 0x90, 0x0d, 0x2f, 0x7d, 0x52, 0x92, 0x6f, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0e, 0x06, 0x2f, 0x8b, 0x50, 0x14, 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x14, 0x42, 0x12, 0x42, 0x01, + 0x42, 0x00, 0x2e, 0x31, 0x6f, 0x98, 0x2e, 0x74, 0xc0, 0x41, 0x6f, 0x80, 0x7f, 0x98, 0x2e, 0x74, 0xc0, 0x82, 0x6f, + 0x10, 0x04, 0x43, 0x52, 0x01, 0x0f, 0x05, 0x2e, 0xcb, 0x00, 0x00, 0x30, 0x04, 0x30, 0x21, 0x2f, 0x51, 0x6f, 0x43, + 0x58, 0x8c, 0x0e, 0x04, 0x30, 0x1c, 0x2f, 0x85, 0x88, 0x41, 0x6f, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, 0x16, 0x2f, + 0x84, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x0f, 0x2f, 0x82, 0x88, 0x31, 0x6f, 0x04, + 0x41, 0x04, 0x05, 0x8c, 0x0e, 0x04, 0x30, 0x08, 0x2f, 0x83, 0x88, 0x00, 0x2e, 0x04, 0x41, 0x8c, 0x0f, 0x04, 0x30, + 0x02, 0x2f, 0x21, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x00, 0x91, 0x14, 0x2f, 0x03, 0x2e, 0xa1, 0x01, 0x41, 0x90, 0x0e, + 0x2f, 0x03, 0x2e, 0xad, 0x01, 0x14, 0x30, 0x4c, 0x28, 0x23, 0x2e, 0xad, 0x01, 0x46, 0xa0, 0x06, 0x2f, 0x81, 0x84, + 0x8d, 0x52, 0x48, 0x82, 0x82, 0x40, 0x21, 0x2e, 0xa1, 0x01, 0x42, 0x42, 0x5c, 0x2c, 0x02, 0x30, 0x05, 0x2e, 0xaa, + 0x01, 0x80, 0xb2, 0x02, 0x30, 0x55, 0x2f, 0x03, 0x2e, 0xa9, 0x01, 0x92, 0x6f, 0xb3, 0x30, 0x98, 0x2e, 0x0f, 0xca, + 0xb2, 0x6f, 0x90, 0x0f, 0x00, 0x30, 0x02, 0x30, 0x4a, 0x2f, 0xa2, 0x6f, 0x87, 0x52, 0x91, 0x00, 0x85, 0x52, 0x51, + 0x0e, 0x02, 0x2f, 0x00, 0x2e, 0x43, 0x2c, 0x02, 0x30, 0xc2, 0x6f, 0x7f, 0x52, 0x91, 0x0e, 0x02, 0x30, 0x3c, 0x2f, + 0x51, 0x6f, 0x81, 0x54, 0x98, 0x2e, 0xfe, 0xc9, 0x10, 0x25, 0xb3, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x32, + 0x6f, 0xc0, 0x7f, 0xb3, 0x30, 0x12, 0x25, 0x98, 0x2e, 0x0f, 0xca, 0x42, 0x6f, 0xb0, 0x7f, 0xb3, 0x30, 0x12, 0x25, + 0x98, 0x2e, 0x0f, 0xca, 0xb2, 0x6f, 0x90, 0x28, 0x83, 0x52, 0x98, 0x2e, 0xfe, 0xc9, 0xc2, 0x6f, 0x90, 0x0f, 0x00, + 0x30, 0x02, 0x30, 0x1d, 0x2f, 0x05, 0x2e, 0xa1, 0x01, 0x80, 0xb2, 0x12, 0x30, 0x0f, 0x2f, 0x42, 0x6f, 0x03, 0x2e, + 0xab, 0x01, 0x91, 0x0e, 0x02, 0x30, 0x12, 0x2f, 0x52, 0x6f, 0x03, 0x2e, 0xac, 0x01, 0x91, 0x0f, 0x02, 0x30, 0x0c, + 0x2f, 0x21, 0x2e, 0xaa, 0x01, 0x0a, 0x2c, 0x12, 0x30, 0x03, 0x2e, 0xcb, 0x00, 0x8d, 0x58, 0x08, 0x89, 0x41, 0x40, + 0x11, 0x43, 0x00, 0x43, 0x25, 0x2e, 0xa1, 0x01, 0xd4, 0x6f, 0x8f, 0x52, 0x00, 0x43, 0x3a, 0x89, 0x00, 0x2e, 0x10, + 0x43, 0x10, 0x43, 0x61, 0x0e, 0xfb, 0x2f, 0x03, 0x2e, 0xa0, 0x01, 0x11, 0x1a, 0x02, 0x2f, 0x02, 0x25, 0x21, 0x2e, + 0xa0, 0x01, 0xeb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x91, 0x52, 0x10, 0x30, 0x02, 0x30, 0x95, 0x56, 0x52, 0x42, 0x4b, + 0x0e, 0xfc, 0x2f, 0x8d, 0x54, 0x88, 0x82, 0x93, 0x56, 0x80, 0x42, 0x53, 0x42, 0x40, 0x42, 0x42, 0x86, 0x83, 0x54, + 0xc0, 0x2e, 0xc2, 0x42, 0x00, 0x2e, 0xa3, 0x52, 0x00, 0x51, 0x52, 0x40, 0x47, 0x40, 0x1a, 0x25, 0x01, 0x2e, 0x97, + 0x00, 0x8f, 0xbe, 0x72, 0x86, 0xfb, 0x7f, 0x0b, 0x30, 0x7c, 0xbf, 0xa5, 0x50, 0x10, 0x08, 0xdf, 0xba, 0x70, 0x88, + 0xf8, 0xbf, 0xcb, 0x42, 0xd3, 0x7f, 0x6c, 0xbb, 0xfc, 0xbb, 0xc5, 0x0a, 0x90, 0x7f, 0x1b, 0x7f, 0x0b, 0x43, 0xc0, + 0xb2, 0xe5, 0x7f, 0xb7, 0x7f, 0xa6, 0x7f, 0xc4, 0x7f, 0x90, 0x2e, 0x1c, 0xb7, 0x07, 0x2e, 0xd2, 0x00, 0xc0, 0xb2, + 0x0b, 0x2f, 0x97, 0x52, 0x01, 0x2e, 0xcd, 0x00, 0x82, 0x7f, 0x98, 0x2e, 0xbb, 0xcc, 0x0b, 0x30, 0x37, 0x2e, 0xd2, + 0x00, 0x82, 0x6f, 0x90, 0x6f, 0x1a, 0x25, 0x00, 0xb2, 0x8b, 0x7f, 0x14, 0x2f, 0xa6, 0xbd, 0x25, 0xbd, 0xb6, 0xb9, + 0x2f, 0xb9, 0x80, 0xb2, 0xd4, 0xb0, 0x0c, 0x2f, 0x99, 0x54, 0x9b, 0x56, 0x0b, 0x30, 0x0b, 0x2e, 0xb1, 0x00, 0xa1, + 0x58, 0x9b, 0x42, 0xdb, 0x42, 0x6c, 0x09, 0x2b, 0x2e, 0xb1, 0x00, 0x8b, 0x42, 0xcb, 0x42, 0x86, 0x7f, 0x73, 0x84, + 0xa7, 0x56, 0xc3, 0x08, 0x39, 0x52, 0x05, 0x50, 0x72, 0x7f, 0x63, 0x7f, 0x98, 0x2e, 0xc2, 0xc0, 0xe1, 0x6f, 0x62, + 0x6f, 0xd1, 0x0a, 0x01, 0x2e, 0xcd, 0x00, 0xd5, 0x6f, 0xc4, 0x6f, 0x72, 0x6f, 0x97, 0x52, 0x9d, 0x5c, 0x98, 0x2e, + 0x06, 0xcd, 0x23, 0x6f, 0x90, 0x6f, 0x99, 0x52, 0xc0, 0xb2, 0x04, 0xbd, 0x54, 0x40, 0xaf, 0xb9, 0x45, 0x40, 0xe1, + 0x7f, 0x02, 0x30, 0x06, 0x2f, 0xc0, 0xb2, 0x02, 0x30, 0x03, 0x2f, 0x9b, 0x5c, 0x12, 0x30, 0x94, 0x43, 0x85, 0x43, + 0x03, 0xbf, 0x6f, 0xbb, 0x80, 0xb3, 0x20, 0x2f, 0x06, 0x6f, 0x26, 0x01, 0x16, 0x6f, 0x6e, 0x03, 0x45, 0x42, 0xc0, + 0x90, 0x29, 0x2e, 0xce, 0x00, 0x9b, 0x52, 0x14, 0x2f, 0x9b, 0x5c, 0x00, 0x2e, 0x93, 0x41, 0x86, 0x41, 0xe3, 0x04, + 0xae, 0x07, 0x80, 0xab, 0x04, 0x2f, 0x80, 0x91, 0x0a, 0x2f, 0x86, 0x6f, 0x73, 0x0f, 0x07, 0x2f, 0x83, 0x6f, 0xc0, + 0xb2, 0x04, 0x2f, 0x54, 0x42, 0x45, 0x42, 0x12, 0x30, 0x04, 0x2c, 0x11, 0x30, 0x02, 0x2c, 0x11, 0x30, 0x11, 0x30, + 0x02, 0xbc, 0x0f, 0xb8, 0xd2, 0x7f, 0x00, 0xb2, 0x0a, 0x2f, 0x01, 0x2e, 0xfc, 0x00, 0x05, 0x2e, 0xc7, 0x01, 0x10, + 0x1a, 0x02, 0x2f, 0x21, 0x2e, 0xc7, 0x01, 0x03, 0x2d, 0x02, 0x2c, 0x01, 0x30, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xd1, 0x6f, 0xa0, 0x6f, 0x98, 0x2e, 0x95, 0xcf, 0xe2, 0x6f, 0x9f, 0x52, 0x01, 0x2e, 0xce, 0x00, 0x82, + 0x40, 0x50, 0x42, 0x0c, 0x2c, 0x42, 0x42, 0x11, 0x30, 0x23, 0x2e, 0xd2, 0x00, 0x01, 0x30, 0xb0, 0x6f, 0x98, 0x2e, + 0x95, 0xcf, 0xa0, 0x6f, 0x01, 0x30, 0x98, 0x2e, 0x95, 0xcf, 0x00, 0x2e, 0xfb, 0x6f, 0x00, 0x5f, 0xb8, 0x2e, 0x83, + 0x86, 0x01, 0x30, 0x00, 0x30, 0x94, 0x40, 0x24, 0x18, 0x06, 0x00, 0x53, 0x0e, 0x4f, 0x02, 0xf9, 0x2f, 0xb8, 0x2e, + 0xa9, 0x52, 0x00, 0x2e, 0x60, 0x40, 0x41, 0x40, 0x0d, 0xbc, 0x98, 0xbc, 0xc0, 0x2e, 0x01, 0x0a, 0x0f, 0xb8, 0xab, + 0x52, 0x53, 0x3c, 0x52, 0x40, 0x40, 0x40, 0x4b, 0x00, 0x82, 0x16, 0x26, 0xb9, 0x01, 0xb8, 0x41, 0x40, 0x10, 0x08, + 0x97, 0xb8, 0x01, 0x08, 0xc0, 0x2e, 0x11, 0x30, 0x01, 0x08, 0x43, 0x86, 0x25, 0x40, 0x04, 0x40, 0xd8, 0xbe, 0x2c, + 0x0b, 0x22, 0x11, 0x54, 0x42, 0x03, 0x80, 0x4b, 0x0e, 0xf6, 0x2f, 0xb8, 0x2e, 0x9f, 0x50, 0x10, 0x50, 0xad, 0x52, + 0x05, 0x2e, 0xd3, 0x00, 0xfb, 0x7f, 0x00, 0x2e, 0x13, 0x40, 0x93, 0x42, 0x41, 0x0e, 0xfb, 0x2f, 0x98, 0x2e, 0xa5, + 0xb7, 0x98, 0x2e, 0x87, 0xcf, 0x01, 0x2e, 0xd9, 0x00, 0x00, 0xb2, 0xfb, 0x6f, 0x0b, 0x2f, 0x01, 0x2e, 0x69, 0xf7, + 0xb1, 0x3f, 0x01, 0x08, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd9, 0x00, 0x21, 0x2e, 0x69, 0xf7, 0x80, 0x2e, 0x7a, + 0xb7, 0xf0, 0x5f, 0xb8, 0x2e, 0x01, 0x2e, 0xc0, 0xf8, 0x03, 0x2e, 0xfc, 0xf5, 0x15, 0x54, 0xaf, 0x56, 0x82, 0x08, + 0x0b, 0x2e, 0x69, 0xf7, 0xcb, 0x0a, 0xb1, 0x58, 0x80, 0x90, 0xdd, 0xbe, 0x4c, 0x08, 0x5f, 0xb9, 0x59, 0x22, 0x80, + 0x90, 0x07, 0x2f, 0x03, 0x34, 0xc3, 0x08, 0xf2, 0x3a, 0x0a, 0x08, 0x02, 0x35, 0xc0, 0x90, 0x4a, 0x0a, 0x48, 0x22, + 0xc0, 0x2e, 0x23, 0x2e, 0xfc, 0xf5, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x56, 0xc7, 0x98, 0x2e, 0x49, 0xc3, 0x10, + 0x30, 0xfb, 0x6f, 0xf0, 0x5f, 0x21, 0x2e, 0xcc, 0x00, 0x21, 0x2e, 0xca, 0x00, 0xb8, 0x2e, 0x03, 0x2e, 0xd3, 0x00, + 0x16, 0xb8, 0x02, 0x34, 0x4a, 0x0c, 0x21, 0x2e, 0x2d, 0xf5, 0xc0, 0x2e, 0x23, 0x2e, 0xd3, 0x00, 0x03, 0xbc, 0x21, + 0x2e, 0xd5, 0x00, 0x03, 0x2e, 0xd5, 0x00, 0x40, 0xb2, 0x10, 0x30, 0x21, 0x2e, 0x77, 0x00, 0x01, 0x30, 0x05, 0x2f, + 0x05, 0x2e, 0xd8, 0x00, 0x80, 0x90, 0x01, 0x2f, 0x23, 0x2e, 0x6f, 0xf5, 0xc0, 0x2e, 0x21, 0x2e, 0xd9, 0x00, 0x11, + 0x30, 0x81, 0x08, 0x01, 0x2e, 0x6a, 0xf7, 0x71, 0x3f, 0x23, 0xbd, 0x01, 0x08, 0x02, 0x0a, 0xc0, 0x2e, 0x21, 0x2e, + 0x6a, 0xf7, 0x30, 0x25, 0x00, 0x30, 0x21, 0x2e, 0x5a, 0xf5, 0x10, 0x50, 0x21, 0x2e, 0x7b, 0x00, 0x21, 0x2e, 0x7c, + 0x00, 0xfb, 0x7f, 0x98, 0x2e, 0xc3, 0xb7, 0x40, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0xfb, 0x6f, 0xf0, 0x5f, 0x03, 0x25, + 0x80, 0x2e, 0xaf, 0xb7, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x01, 0x2e, 0x5d, 0xf7, 0x08, 0xbc, 0x80, 0xac, 0x0e, 0xbb, 0x02, 0x2f, + 0x00, 0x30, 0x41, 0x04, 0x82, 0x06, 0xc0, 0xa4, 0x00, 0x30, 0x11, 0x2f, 0x40, 0xa9, 0x03, 0x2f, 0x40, 0x91, 0x0d, + 0x2f, 0x00, 0xa7, 0x0b, 0x2f, 0x80, 0xb3, 0xb3, 0x58, 0x02, 0x2f, 0x90, 0xa1, 0x26, 0x13, 0x20, 0x23, 0x80, 0x90, + 0x10, 0x30, 0x01, 0x2f, 0xcc, 0x0e, 0x00, 0x2f, 0x00, 0x30, 0xb8, 0x2e, 0xb5, 0x50, 0x18, 0x08, 0x08, 0xbc, 0x88, + 0xb6, 0x0d, 0x17, 0xc6, 0xbd, 0x56, 0xbc, 0xb7, 0x58, 0xda, 0xba, 0x04, 0x01, 0x1d, 0x0a, 0x10, 0x50, 0x05, 0x30, + 0x32, 0x25, 0x45, 0x03, 0xfb, 0x7f, 0xf6, 0x30, 0x21, 0x25, 0x98, 0x2e, 0x37, 0xca, 0x16, 0xb5, 0x9a, 0xbc, 0x06, + 0xb8, 0x80, 0xa8, 0x41, 0x0a, 0x0e, 0x2f, 0x80, 0x90, 0x02, 0x2f, 0x2d, 0x50, 0x48, 0x0f, 0x09, 0x2f, 0xbf, 0xa0, + 0x04, 0x2f, 0xbf, 0x90, 0x06, 0x2f, 0xb7, 0x54, 0xca, 0x0f, 0x03, 0x2f, 0x00, 0x2e, 0x02, 0x2c, 0xb7, 0x52, 0x2d, + 0x52, 0xf2, 0x33, 0x98, 0x2e, 0xd9, 0xc0, 0xfb, 0x6f, 0xf1, 0x37, 0xc0, 0x2e, 0x01, 0x08, 0xf0, 0x5f, 0xbf, 0x56, + 0xb9, 0x54, 0xd0, 0x40, 0xc4, 0x40, 0x0b, 0x2e, 0xfd, 0xf3, 0xbf, 0x52, 0x90, 0x42, 0x94, 0x42, 0x95, 0x42, 0x05, + 0x30, 0xc1, 0x50, 0x0f, 0x88, 0x06, 0x40, 0x04, 0x41, 0x96, 0x42, 0xc5, 0x42, 0x48, 0xbe, 0x73, 0x30, 0x0d, 0x2e, + 0xd8, 0x00, 0x4f, 0xba, 0x84, 0x42, 0x03, 0x42, 0x81, 0xb3, 0x02, 0x2f, 0x2b, 0x2e, 0x6f, 0xf5, 0x06, 0x2d, 0x05, + 0x2e, 0x77, 0xf7, 0xbd, 0x56, 0x93, 0x08, 0x25, 0x2e, 0x77, 0xf7, 0xbb, 0x54, 0x25, 0x2e, 0xc2, 0xf5, 0x07, 0x2e, + 0xfd, 0xf3, 0x42, 0x30, 0xb4, 0x33, 0xda, 0x0a, 0x4c, 0x00, 0x27, 0x2e, 0xfd, 0xf3, 0x43, 0x40, 0xd4, 0x3f, 0xdc, + 0x08, 0x43, 0x42, 0x00, 0x2e, 0x00, 0x2e, 0x43, 0x40, 0x24, 0x30, 0xdc, 0x0a, 0x43, 0x42, 0x04, 0x80, 0x03, 0x2e, + 0xfd, 0xf3, 0x4a, 0x0a, 0x23, 0x2e, 0xfd, 0xf3, 0x61, 0x34, 0xc0, 0x2e, 0x01, 0x42, 0x00, 0x2e, 0x60, 0x50, 0x1a, + 0x25, 0x7a, 0x86, 0xe0, 0x7f, 0xf3, 0x7f, 0x03, 0x25, 0xc3, 0x52, 0x41, 0x84, 0xdb, 0x7f, 0x33, 0x30, 0x98, 0x2e, + 0x16, 0xc2, 0x1a, 0x25, 0x7d, 0x82, 0xf0, 0x6f, 0xe2, 0x6f, 0x32, 0x25, 0x16, 0x40, 0x94, 0x40, 0x26, 0x01, 0x85, + 0x40, 0x8e, 0x17, 0xc4, 0x42, 0x6e, 0x03, 0x95, 0x42, 0x41, 0x0e, 0xf4, 0x2f, 0xdb, 0x6f, 0xa0, 0x5f, 0xb8, 0x2e, + 0xb0, 0x51, 0xfb, 0x7f, 0x98, 0x2e, 0xe8, 0x0d, 0x5a, 0x25, 0x98, 0x2e, 0x0f, 0x0e, 0xcb, 0x58, 0x32, 0x87, 0xc4, + 0x7f, 0x65, 0x89, 0x6b, 0x8d, 0xc5, 0x5a, 0x65, 0x7f, 0xe1, 0x7f, 0x83, 0x7f, 0xa6, 0x7f, 0x74, 0x7f, 0xd0, 0x7f, + 0xb6, 0x7f, 0x94, 0x7f, 0x17, 0x30, 0xc7, 0x52, 0xc9, 0x54, 0x51, 0x7f, 0x00, 0x2e, 0x85, 0x6f, 0x42, 0x7f, 0x00, + 0x2e, 0x51, 0x41, 0x45, 0x81, 0x42, 0x41, 0x13, 0x40, 0x3b, 0x8a, 0x00, 0x40, 0x4b, 0x04, 0xd0, 0x06, 0xc0, 0xac, + 0x85, 0x7f, 0x02, 0x2f, 0x02, 0x30, 0x51, 0x04, 0xd3, 0x06, 0x41, 0x84, 0x05, 0x30, 0x5d, 0x02, 0xc9, 0x16, 0xdf, + 0x08, 0xd3, 0x00, 0x8d, 0x02, 0xaf, 0xbc, 0xb1, 0xb9, 0x59, 0x0a, 0x65, 0x6f, 0x11, 0x43, 0xa1, 0xb4, 0x52, 0x41, + 0x53, 0x41, 0x01, 0x43, 0x34, 0x7f, 0x65, 0x7f, 0x26, 0x31, 0xe5, 0x6f, 0xd4, 0x6f, 0x98, 0x2e, 0x37, 0xca, 0x32, + 0x6f, 0x75, 0x6f, 0x83, 0x40, 0x42, 0x41, 0x23, 0x7f, 0x12, 0x7f, 0xf6, 0x30, 0x40, 0x25, 0x51, 0x25, 0x98, 0x2e, + 0x37, 0xca, 0x14, 0x6f, 0x20, 0x05, 0x70, 0x6f, 0x25, 0x6f, 0x69, 0x07, 0xa2, 0x6f, 0x31, 0x6f, 0x0b, 0x30, 0x04, + 0x42, 0x9b, 0x42, 0x8b, 0x42, 0x55, 0x42, 0x32, 0x7f, 0x40, 0xa9, 0xc3, 0x6f, 0x71, 0x7f, 0x02, 0x30, 0xd0, 0x40, + 0xc3, 0x7f, 0x03, 0x2f, 0x40, 0x91, 0x15, 0x2f, 0x00, 0xa7, 0x13, 0x2f, 0x00, 0xa4, 0x11, 0x2f, 0x84, 0xbd, 0x98, + 0x2e, 0x79, 0xca, 0x55, 0x6f, 0xb7, 0x54, 0x54, 0x41, 0x82, 0x00, 0xf3, 0x3f, 0x45, 0x41, 0xcb, 0x02, 0xf6, 0x30, + 0x98, 0x2e, 0x37, 0xca, 0x35, 0x6f, 0xa4, 0x6f, 0x41, 0x43, 0x03, 0x2c, 0x00, 0x43, 0xa4, 0x6f, 0x35, 0x6f, 0x17, + 0x30, 0x42, 0x6f, 0x51, 0x6f, 0x93, 0x40, 0x42, 0x82, 0x00, 0x41, 0xc3, 0x00, 0x03, 0x43, 0x51, 0x7f, 0x00, 0x2e, + 0x94, 0x40, 0x41, 0x41, 0x4c, 0x02, 0xc4, 0x6f, 0xd1, 0x56, 0x63, 0x0e, 0x74, 0x6f, 0x51, 0x43, 0xa5, 0x7f, 0x8a, + 0x2f, 0x09, 0x2e, 0xd8, 0x00, 0x01, 0xb3, 0x21, 0x2f, 0xcb, 0x58, 0x90, 0x6f, 0x13, 0x41, 0xb6, 0x6f, 0xe4, 0x7f, + 0x00, 0x2e, 0x91, 0x41, 0x14, 0x40, 0x92, 0x41, 0x15, 0x40, 0x17, 0x2e, 0x6f, 0xf5, 0xb6, 0x7f, 0xd0, 0x7f, 0xcb, + 0x7f, 0x98, 0x2e, 0x00, 0x0c, 0x07, 0x15, 0xc2, 0x6f, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0xc3, 0xa3, 0xc1, 0x8f, + 0xe4, 0x6f, 0xd0, 0x6f, 0xe6, 0x2f, 0x14, 0x30, 0x05, 0x2e, 0x6f, 0xf5, 0x14, 0x0b, 0x29, 0x2e, 0x6f, 0xf5, 0x18, + 0x2d, 0xcd, 0x56, 0x04, 0x32, 0xb5, 0x6f, 0x1c, 0x01, 0x51, 0x41, 0x52, 0x41, 0xc3, 0x40, 0xb5, 0x7f, 0xe4, 0x7f, + 0x98, 0x2e, 0x1f, 0x0c, 0xe4, 0x6f, 0x21, 0x87, 0x00, 0x43, 0x04, 0x32, 0xcf, 0x54, 0x5a, 0x0e, 0xef, 0x2f, 0x15, + 0x54, 0x09, 0x2e, 0x77, 0xf7, 0x22, 0x0b, 0x29, 0x2e, 0x77, 0xf7, 0xfb, 0x6f, 0x50, 0x5e, 0xb8, 0x2e, 0x10, 0x50, + 0x01, 0x2e, 0xd4, 0x00, 0x00, 0xb2, 0xfb, 0x7f, 0x51, 0x2f, 0x01, 0xb2, 0x48, 0x2f, 0x02, 0xb2, 0x42, 0x2f, 0x03, + 0x90, 0x56, 0x2f, 0xd7, 0x52, 0x79, 0x80, 0x42, 0x40, 0x81, 0x84, 0x00, 0x40, 0x42, 0x42, 0x98, 0x2e, 0x93, 0x0c, + 0xd9, 0x54, 0xd7, 0x50, 0xa1, 0x40, 0x98, 0xbd, 0x82, 0x40, 0x3e, 0x82, 0xda, 0x0a, 0x44, 0x40, 0x8b, 0x16, 0xe3, + 0x00, 0x53, 0x42, 0x00, 0x2e, 0x43, 0x40, 0x9a, 0x02, 0x52, 0x42, 0x00, 0x2e, 0x41, 0x40, 0x15, 0x54, 0x4a, 0x0e, + 0x3a, 0x2f, 0x3a, 0x82, 0x00, 0x30, 0x41, 0x40, 0x21, 0x2e, 0x85, 0x0f, 0x40, 0xb2, 0x0a, 0x2f, 0x98, 0x2e, 0xb1, + 0x0c, 0x98, 0x2e, 0x45, 0x0e, 0x98, 0x2e, 0x5b, 0x0e, 0xfb, 0x6f, 0xf0, 0x5f, 0x00, 0x30, 0x80, 0x2e, 0xce, 0xb7, + 0xdd, 0x52, 0xd3, 0x54, 0x42, 0x42, 0x4f, 0x84, 0x73, 0x30, 0xdb, 0x52, 0x83, 0x42, 0x1b, 0x30, 0x6b, 0x42, 0x23, + 0x30, 0x27, 0x2e, 0xd7, 0x00, 0x37, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0xd6, 0x00, 0x7a, 0x84, 0x17, 0x2c, 0x42, 0x42, + 0x30, 0x30, 0x21, 0x2e, 0xd4, 0x00, 0x12, 0x2d, 0x21, 0x30, 0x00, 0x30, 0x23, 0x2e, 0xd4, 0x00, 0x21, 0x2e, 0x7b, + 0xf7, 0x0b, 0x2d, 0x17, 0x30, 0x98, 0x2e, 0x51, 0x0c, 0xd5, 0x50, 0x0c, 0x82, 0x72, 0x30, 0x2f, 0x2e, 0xd4, 0x00, + 0x25, 0x2e, 0x7b, 0xf7, 0x40, 0x42, 0x00, 0x2e, 0xfb, 0x6f, 0xf0, 0x5f, 0xb8, 0x2e, 0x70, 0x50, 0x0a, 0x25, 0x39, + 0x86, 0xfb, 0x7f, 0xe1, 0x32, 0x62, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xb5, 0x56, 0xa5, 0x6f, 0xab, 0x08, 0x91, 0x6f, + 0x4b, 0x08, 0xdf, 0x56, 0xc4, 0x6f, 0x23, 0x09, 0x4d, 0xba, 0x93, 0xbc, 0x8c, 0x0b, 0xd1, 0x6f, 0x0b, 0x09, 0xcb, + 0x52, 0xe1, 0x5e, 0x56, 0x42, 0xaf, 0x09, 0x4d, 0xba, 0x23, 0xbd, 0x94, 0x0a, 0xe5, 0x6f, 0x68, 0xbb, 0xeb, 0x08, + 0xbd, 0xb9, 0x63, 0xbe, 0xfb, 0x6f, 0x52, 0x42, 0xe3, 0x0a, 0xc0, 0x2e, 0x43, 0x42, 0x90, 0x5f, 0xd1, 0x50, 0x03, + 0x2e, 0x25, 0xf3, 0x13, 0x40, 0x00, 0x40, 0x9b, 0xbc, 0x9b, 0xb4, 0x08, 0xbd, 0xb8, 0xb9, 0x98, 0xbc, 0xda, 0x0a, + 0x08, 0xb6, 0x89, 0x16, 0xc0, 0x2e, 0x19, 0x00, 0x62, 0x02, 0x10, 0x50, 0xfb, 0x7f, 0x98, 0x2e, 0x81, 0x0d, 0x01, + 0x2e, 0xd4, 0x00, 0x31, 0x30, 0x08, 0x04, 0xfb, 0x6f, 0x01, 0x30, 0xf0, 0x5f, 0x23, 0x2e, 0xd6, 0x00, 0x21, 0x2e, + 0xd7, 0x00, 0xb8, 0x2e, 0x01, 0x2e, 0xd7, 0x00, 0x03, 0x2e, 0xd6, 0x00, 0x48, 0x0e, 0x01, 0x2f, 0x80, 0x2e, 0x1f, + 0x0e, 0xb8, 0x2e, 0xe3, 0x50, 0x21, 0x34, 0x01, 0x42, 0x82, 0x30, 0xc1, 0x32, 0x25, 0x2e, 0x62, 0xf5, 0x01, 0x00, + 0x22, 0x30, 0x01, 0x40, 0x4a, 0x0a, 0x01, 0x42, 0xb8, 0x2e, 0xe3, 0x54, 0xf0, 0x3b, 0x83, 0x40, 0xd8, 0x08, 0xe5, + 0x52, 0x83, 0x42, 0x00, 0x30, 0x83, 0x30, 0x50, 0x42, 0xc4, 0x32, 0x27, 0x2e, 0x64, 0xf5, 0x94, 0x00, 0x50, 0x42, + 0x40, 0x42, 0xd3, 0x3f, 0x84, 0x40, 0x7d, 0x82, 0xe3, 0x08, 0x40, 0x42, 0x83, 0x42, 0xb8, 0x2e, 0xdd, 0x52, 0x00, + 0x30, 0x40, 0x42, 0x7c, 0x86, 0xb9, 0x52, 0x09, 0x2e, 0x70, 0x0f, 0xbf, 0x54, 0xc4, 0x42, 0xd3, 0x86, 0x54, 0x40, + 0x55, 0x40, 0x94, 0x42, 0x85, 0x42, 0x21, 0x2e, 0xd7, 0x00, 0x42, 0x40, 0x25, 0x2e, 0xfd, 0xf3, 0xc0, 0x42, 0x7e, + 0x82, 0x05, 0x2e, 0x7d, 0x00, 0x80, 0xb2, 0x14, 0x2f, 0x05, 0x2e, 0x89, 0x00, 0x27, 0xbd, 0x2f, 0xb9, 0x80, 0x90, + 0x02, 0x2f, 0x21, 0x2e, 0x6f, 0xf5, 0x0c, 0x2d, 0x07, 0x2e, 0x71, 0x0f, 0x14, 0x30, 0x1c, 0x09, 0x05, 0x2e, 0x77, + 0xf7, 0xbd, 0x56, 0x47, 0xbe, 0x93, 0x08, 0x94, 0x0a, 0x25, 0x2e, 0x77, 0xf7, 0xe7, 0x54, 0x50, 0x42, 0x4a, 0x0e, + 0xfc, 0x2f, 0xb8, 0x2e, 0x50, 0x50, 0x02, 0x30, 0x43, 0x86, 0xe5, 0x50, 0xfb, 0x7f, 0xe3, 0x7f, 0xd2, 0x7f, 0xc0, + 0x7f, 0xb1, 0x7f, 0x00, 0x2e, 0x41, 0x40, 0x00, 0x40, 0x48, 0x04, 0x98, 0x2e, 0x74, 0xc0, 0x1e, 0xaa, 0xd3, 0x6f, + 0x14, 0x30, 0xb1, 0x6f, 0xe3, 0x22, 0xc0, 0x6f, 0x52, 0x40, 0xe4, 0x6f, 0x4c, 0x0e, 0x12, 0x42, 0xd3, 0x7f, 0xeb, + 0x2f, 0x03, 0x2e, 0x86, 0x0f, 0x40, 0x90, 0x11, 0x30, 0x03, 0x2f, 0x23, 0x2e, 0x86, 0x0f, 0x02, 0x2c, 0x00, 0x30, + 0xd0, 0x6f, 0xfb, 0x6f, 0xb0, 0x5f, 0xb8, 0x2e, 0x40, 0x50, 0xf1, 0x7f, 0x0a, 0x25, 0x3c, 0x86, 0xeb, 0x7f, 0x41, + 0x33, 0x22, 0x30, 0x98, 0x2e, 0xc2, 0xc4, 0xd3, 0x6f, 0xf4, 0x30, 0xdc, 0x09, 0x47, 0x58, 0xc2, 0x6f, 0x94, 0x09, + 0xeb, 0x58, 0x6a, 0xbb, 0xdc, 0x08, 0xb4, 0xb9, 0xb1, 0xbd, 0xe9, 0x5a, 0x95, 0x08, 0x21, 0xbd, 0xf6, 0xbf, 0x77, + 0x0b, 0x51, 0xbe, 0xf1, 0x6f, 0xeb, 0x6f, 0x52, 0x42, 0x54, 0x42, 0xc0, 0x2e, 0x43, 0x42, 0xc0, 0x5f, 0x50, 0x50, + 0xf5, 0x50, 0x31, 0x30, 0x11, 0x42, 0xfb, 0x7f, 0x7b, 0x30, 0x0b, 0x42, 0x11, 0x30, 0x02, 0x80, 0x23, 0x33, 0x01, + 0x42, 0x03, 0x00, 0x07, 0x2e, 0x80, 0x03, 0x05, 0x2e, 0xd3, 0x00, 0x23, 0x52, 0xe2, 0x7f, 0xd3, 0x7f, 0xc0, 0x7f, + 0x98, 0x2e, 0xb6, 0x0e, 0xd1, 0x6f, 0x08, 0x0a, 0x1a, 0x25, 0x7b, 0x86, 0xd0, 0x7f, 0x01, 0x33, 0x12, 0x30, 0x98, + 0x2e, 0xc2, 0xc4, 0xd1, 0x6f, 0x08, 0x0a, 0x00, 0xb2, 0x0d, 0x2f, 0xe3, 0x6f, 0x01, 0x2e, 0x80, 0x03, 0x51, 0x30, + 0xc7, 0x86, 0x23, 0x2e, 0x21, 0xf2, 0x08, 0xbc, 0xc0, 0x42, 0x98, 0x2e, 0xa5, 0xb7, 0x00, 0x2e, 0x00, 0x2e, 0xd0, + 0x2e, 0xb0, 0x6f, 0x0b, 0xb8, 0x03, 0x2e, 0x1b, 0x00, 0x08, 0x1a, 0xb0, 0x7f, 0x70, 0x30, 0x04, 0x2f, 0x21, 0x2e, + 0x21, 0xf2, 0x00, 0x2e, 0x00, 0x2e, 0xd0, 0x2e, 0x98, 0x2e, 0x6d, 0xc0, 0x98, 0x2e, 0x5d, 0xc0, 0xed, 0x50, 0x98, + 0x2e, 0x44, 0xcb, 0xef, 0x50, 0x98, 0x2e, 0x46, 0xc3, 0xf1, 0x50, 0x98, 0x2e, 0x53, 0xc7, 0x35, 0x50, 0x98, 0x2e, + 0x64, 0xcf, 0x10, 0x30, 0x98, 0x2e, 0xdc, 0x03, 0x20, 0x26, 0xc0, 0x6f, 0x02, 0x31, 0x12, 0x42, 0xab, 0x33, 0x0b, + 0x42, 0x37, 0x80, 0x01, 0x30, 0x01, 0x42, 0xf3, 0x37, 0xf7, 0x52, 0xfb, 0x50, 0x44, 0x40, 0xa2, 0x0a, 0x42, 0x42, + 0x8b, 0x31, 0x09, 0x2e, 0x5e, 0xf7, 0xf9, 0x54, 0xe3, 0x08, 0x83, 0x42, 0x1b, 0x42, 0x23, 0x33, 0x4b, 0x00, 0xbc, + 0x84, 0x0b, 0x40, 0x33, 0x30, 0x83, 0x42, 0x0b, 0x42, 0xe0, 0x7f, 0xd1, 0x7f, 0x98, 0x2e, 0x58, 0xb7, 0xd1, 0x6f, + 0x80, 0x30, 0x40, 0x42, 0x03, 0x30, 0xe0, 0x6f, 0xf3, 0x54, 0x04, 0x30, 0x00, 0x2e, 0x00, 0x2e, 0x01, 0x89, 0x62, + 0x0e, 0xfa, 0x2f, 0x43, 0x42, 0x11, 0x30, 0xfb, 0x6f, 0xc0, 0x2e, 0x01, 0x42, 0xb0, 0x5f, 0xc1, 0x4a, 0x00, 0x00, + 0x6d, 0x57, 0x00, 0x00, 0x77, 0x8e, 0x00, 0x00, 0xe0, 0xff, 0xff, 0xff, 0xd3, 0xff, 0xff, 0xff, 0xe5, 0xff, 0xff, + 0xff, 0xee, 0xe1, 0xff, 0xff, 0x7c, 0x13, 0x00, 0x00, 0x46, 0xe6, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, + 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, + 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, + 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, 0x2e, 0x00, 0xc1, 0x80, + 0x2e, 0x00, 0xc1 + +}; + +} // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py new file mode 100644 index 0000000000..ad36e592d1 --- /dev/null +++ b/esphome/components/bmi270/motion.py @@ -0,0 +1,92 @@ +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 esphome.types import ConfigType + +from . import BMI270Component, bmi270_ns + +DEPENDENCIES = ["i2c"] + +# Enum proxies (must match the C++ enum values exactly) +BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, + "4G": BMI270AccelRange.BMI270_ACCEL_RANGE_4G, + "8G": BMI270AccelRange.BMI270_ACCEL_RANGE_8G, + "16G": BMI270AccelRange.BMI270_ACCEL_RANGE_16G, +} + +BMI270GyroRange = bmi270_ns.enum("BMI270GyroRange") +GYRO_RANGE_OPTIONS = { + "2000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_2000, + "1000DPS": BMI270GyroRange.BMI270_GYRO_RANGE_1000, + "500DPS": BMI270GyroRange.BMI270_GYRO_RANGE_500, + "250DPS": BMI270GyroRange.BMI270_GYRO_RANGE_250, + "125DPS": BMI270GyroRange.BMI270_GYRO_RANGE_125, +} + +BMI270AccelODR = bmi270_ns.enum("BMI270AccelODR") +ACCEL_ODR_OPTIONS = { + "12_5HZ": BMI270AccelODR.BMI270_ACCEL_ODR_12_5, + "25HZ": BMI270AccelODR.BMI270_ACCEL_ODR_25, + "50HZ": BMI270AccelODR.BMI270_ACCEL_ODR_50, + "100HZ": BMI270AccelODR.BMI270_ACCEL_ODR_100, + "200HZ": BMI270AccelODR.BMI270_ACCEL_ODR_200, + "400HZ": BMI270AccelODR.BMI270_ACCEL_ODR_400, + "800HZ": BMI270AccelODR.BMI270_ACCEL_ODR_800, + "1600HZ": BMI270AccelODR.BMI270_ACCEL_ODR_1600, +} + +BMI270GyroODR = bmi270_ns.enum("BMI270GyroODR") +GYRO_ODR_OPTIONS = { + "25HZ": BMI270GyroODR.BMI270_GYRO_ODR_25, + "50HZ": BMI270GyroODR.BMI270_GYRO_ODR_50, + "100HZ": BMI270GyroODR.BMI270_GYRO_ODR_100, + "200HZ": BMI270GyroODR.BMI270_GYRO_ODR_200, + "400HZ": BMI270GyroODR.BMI270_GYRO_ODR_400, + "800HZ": BMI270GyroODR.BMI270_GYRO_ODR_800, + "1600HZ": BMI270GyroODR.BMI270_GYRO_ODR_1600, + "3200HZ": BMI270GyroODR.BMI270_GYRO_ODR_3200, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(BMI270Component, 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="100HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x68)) +) + + +# Code generation +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + # Accelerometer sensors + # 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/bmi270/sensor.py b/esphome/components/bmi270/sensor.py new file mode 100644 index 0000000000..0e1b0604a3 --- /dev/null +++ b/esphome/components/bmi270/sensor.py @@ -0,0 +1,42 @@ +# 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 esphome.types import ConfigType + +from . import CONF_BMI270_ID, BMI270Component + +AUTO_LOAD = ["bmi270"] + +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_BMI270_ID): cv.use_id(BMI270Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_BMI270_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/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/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index 6e51984e1f..e4e559844e 100644 --- a/esphome/components/bmp085/sensor.py +++ b/esphome/components/bmp085/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_HECTOPASCAL, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index d612920dd4..c0f0ae90bf 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -13,6 +13,8 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_HECTOPASCAL, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@ademuri"] @@ -69,7 +71,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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_i2c/sensor.py b/esphome/components/bmp280_i2c/sensor.py index 3ff556d51a..8e3c14f50a 100644 --- a/esphome/components/bmp280_i2c/sensor.py +++ b/esphome/components/bmp280_i2c/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv +from esphome.types import ConfigType from ..bmp280_base import CONFIG_SCHEMA_BASE, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend({cv.GenerateID(): cv.declare_id(BMP280I2CComponent)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await i2c.register_i2c_device(var, config) 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/bmp280_spi/sensor.py b/esphome/components/bmp280_spi/sensor.py index b3678ec01d..d97a6ea579 100644 --- a/esphome/components/bmp280_spi/sensor.py +++ b/esphome/components/bmp280_spi/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv +from esphome.types import ConfigType from ..bmp280_base import CONFIG_SCHEMA_BASE, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend({cv.GenerateID(): cv.declare_id(BMP280SPIComponent)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await spi.register_spi_device(var, config) diff --git a/esphome/components/bmp3xx_base/__init__.py b/esphome/components/bmp3xx_base/__init__.py index c31db31761..75e168378e 100644 --- a/esphome/components/bmp3xx_base/__init__.py +++ b/esphome/components/bmp3xx_base/__init__.py @@ -13,6 +13,8 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_HECTOPASCAL, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@martgras", "@latonita"] @@ -73,7 +75,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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_i2c/sensor.py b/esphome/components/bmp3xx_i2c/sensor.py index 6fed9fc9ee..46e50ea39f 100644 --- a/esphome/components/bmp3xx_i2c/sensor.py +++ b/esphome/components/bmp3xx_i2c/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c +from esphome.types import ConfigType from ..bmp3xx_base import CONFIG_SCHEMA_BASE, cv, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend({cv.GenerateID(): cv.declare_id(BMP3XXI2CComponent)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await i2c.register_i2c_device(var, config) 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/bmp3xx_spi/sensor.py b/esphome/components/bmp3xx_spi/sensor.py index 22aab71977..fb3580bbad 100644 --- a/esphome/components/bmp3xx_spi/sensor.py +++ b/esphome/components/bmp3xx_spi/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import spi +from esphome.types import ConfigType from ..bmp3xx_base import CONFIG_SCHEMA_BASE, cv, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend(spi.spi_device_schema()).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await spi.register_spi_device(var, config) diff --git a/esphome/components/bmp581_base/__init__.py b/esphome/components/bmp581_base/__init__.py index 6a7cf45089..1c2c5c37d4 100644 --- a/esphome/components/bmp581_base/__init__.py +++ b/esphome/components/bmp581_base/__init__.py @@ -15,6 +15,8 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PASCAL, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@kahrendt", "@danielkent-net"] @@ -47,7 +49,7 @@ IIR_FILTER_OPTIONS = { BMP581Component = bmp581_ns.class_("BMP581Component", cg.PollingComponent) -def compute_measurement_conversion_time(config): +def compute_measurement_conversion_time(config: ConfigType) -> int: # - adds up sensor conversion time based on temperature and pressure oversampling rates given in datasheet # - returns a rounded up time in ms @@ -132,7 +134,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if temperature_config := config.get(CONF_TEMPERATURE): 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_i2c/sensor.py b/esphome/components/bmp581_i2c/sensor.py index 42645022a6..b4cd00325d 100644 --- a/esphome/components/bmp581_i2c/sensor.py +++ b/esphome/components/bmp581_i2c/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv +from esphome.types import ConfigType from ..bmp581_base import CONFIG_SCHEMA_BASE, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend({cv.GenerateID(): cv.declare_id(BMP581I2CComponent)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await i2c.register_i2c_device(var, config) 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/bmp581_spi/sensor.py b/esphome/components/bmp581_spi/sensor.py index db0d0cd529..435c5cd6f9 100644 --- a/esphome/components/bmp581_spi/sensor.py +++ b/esphome/components/bmp581_spi/sensor.py @@ -4,6 +4,7 @@ import esphome.codegen as cg from esphome.components import spi from esphome.components.spi import CONF_SPI_MODE import esphome.config_validation as cv +from esphome.types import ConfigType from ..bmp581_base import CONFIG_SCHEMA_BASE, to_code_base @@ -28,7 +29,7 @@ BMP581SPIComponent = bmp581_ns.class_( ) -def check_spi_mode(config): +def check_spi_mode(config: ConfigType) -> ConfigType: spi_mode = config.get(CONF_SPI_MODE) if spi_mode not in VALID_SPI_MODES: raise cv.Invalid("BMP581 only supports SPI mode 0 or mode 3") @@ -43,6 +44,6 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await spi.register_spi_device(var, config) diff --git a/esphome/components/bp1658cj/__init__.py b/esphome/components/bp1658cj/__init__.py index dc80c67b44..b45272d2ea 100644 --- a/esphome/components/bp1658cj/__init__.py +++ b/esphome/components/bp1658cj/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] MULTI_CONF = True @@ -28,7 +29,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/bp1658cj/output.py b/esphome/components/bp1658cj/output.py index 78cf717aba..93e3c75daf 100644 --- a/esphome/components/bp1658cj/output.py +++ b/esphome/components/bp1658cj/output.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType from . import BP1658CJ @@ -19,7 +20,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) diff --git a/esphome/components/bp5758d/__init__.py b/esphome/components/bp5758d/__init__.py index af78b38ef5..fa4e8a231b 100644 --- a/esphome/components/bp5758d/__init__.py +++ b/esphome/components/bp5758d/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] MULTI_CONF = True @@ -19,7 +20,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/bp5758d/output.py b/esphome/components/bp5758d/output.py index 9adf13de55..bbca7c18cc 100644 --- a/esphome/components/bp5758d/output.py +++ b/esphome/components/bp5758d/output.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_CURRENT, CONF_ID +from esphome.types import ConfigType from . import BP5758D @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 8ce216da22..ed0cbaa9e1 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -1,24 +1,28 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_BINDKEY, CONF_ID, CONF_MAC_ADDRESS from esphome.core import HexInt +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@nagyrobi"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] -BLE_DEVICE_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") BTHomeMiThermometer = bthome_mithermometer_ns.class_( - "BTHomeMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "BTHomeMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component ) -def bthome_mithermometer_base_schema(extra_schema=None): +def bthome_mithermometer_base_schema( + extra_schema: cv.Schema | dict | None = None, +) -> cv.All: if extra_schema is None: extra_schema = {} - return ( + return cv.All( + ble_device_base.rename_legacy_hub_id("bthome_mithermometer"), cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(BTHomeMiThermometer), @@ -26,15 +30,15 @@ def bthome_mithermometer_base_schema(extra_schema=None): cv.Optional(CONF_BINDKEY): cv.bind_key, } ) - .extend(BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) .extend(extra_schema) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) -async def setup_bthome_mithermometer(var, config): +async def setup_bthome_mithermometer(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) if bindkey := config.get(CONF_BINDKEY): bindkey_bytes = [ diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index ff12e6157d..1ebabea0a3 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -8,13 +8,20 @@ #include #include +// AES-CCM backend for encrypted-advertisement (bindkey) decryption: +// - ESP32 + ESP-IDF >= 6.0 -> PSA crypto (psa_aead_decrypt), hardware-backed. +// - every other platform -> the portable software AES-CCM in ble_device_base, so +// decryption never depends on the SDK exposing mbedtls/PSA to application code +// (e.g. LibreTiny beken-72xx keeps its mbedtls internal). Works on any BLE platform. #ifdef USE_ESP32 - #include #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #include -#else -#include "mbedtls/ccm.h" +#define BTHOME_CRYPTO_PSA +#endif +#endif +#ifndef BTHOME_CRYPTO_PSA +#include "esphome/components/ble_device_base/ble_aes_ccm.h" #endif namespace esphome::bthome_mithermometer { @@ -25,6 +32,9 @@ static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; static constexpr size_t BTHOME_COUNTER_SIZE = 4; +// Both callers are log macros (LOGCONFIG / LOGVV); below CONFIG level they +// compile away and an ungated helper trips -Wunused-function. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG static const char *format_mac_address(std::span buffer, uint64_t address) { std::array mac{}; for (size_t i = 0; i < MAC_ADDRESS_SIZE; i++) { @@ -34,6 +44,7 @@ static const char *format_mac_address(std::span= ESPHOME_LOG_LEVEL_CONFIG static bool get_bthome_value_length(uint8_t obj_type, size_t &value_length) { switch (obj_type) { @@ -153,7 +164,7 @@ void BTHomeMiThermometer::dump_config() { LOG_SENSOR(" ", "Signal Strength", this->signal_strength_); } -bool BTHomeMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) { bool matched = false; for (auto &service_data : device.get_service_datas()) { if (this->handle_service_data_(service_data, device)) { @@ -200,7 +211,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da const uint8_t *ciphertext = data.data() + 1; const uint8_t *mic = data.data() + data.size() - BTHOME_MIC_SIZE; -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#if defined(BTHOME_CRYPTO_PSA) // PSA AEAD expects ciphertext + tag concatenated // BLE advertisement max payload is 31 bytes, so this is always sufficient static constexpr size_t MAX_CT_WITH_TAG = 32; @@ -222,6 +233,7 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da } size_t plaintext_length; + // NOLINTNEXTLINE(readability-suspicious-call-argument) - similarly named size args are not swapped psa_status_t status = psa_aead_decrypt(key_id, PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, BTHOME_MIC_SIZE), nonce.data(), nonce.size(), nullptr, 0, ct_with_tag, ct_with_tag_size, payload.data(), ciphertext_size, &plaintext_length); @@ -231,29 +243,18 @@ bool BTHomeMiThermometer::decrypt_bthome_payload_(const std::vector &da return false; } #else - mbedtls_ccm_context ctx; - mbedtls_ccm_init(&ctx); - - int ret = mbedtls_ccm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES, this->bindkey_, BTHOME_BINDKEY_SIZE * 8); - if (ret) { - ESP_LOGVV(TAG, "mbedtls_ccm_setkey() failed."); - mbedtls_ccm_free(&ctx); - return false; - } - - ret = mbedtls_ccm_auth_decrypt(&ctx, ciphertext_size, nonce.data(), nonce.size(), nullptr, 0, ciphertext, - payload.data(), mic, BTHOME_MIC_SIZE); - mbedtls_ccm_free(&ctx); - if (ret) { - ESP_LOGVV(TAG, "BTHome decryption failed (ret=%d).", ret); + // Portable software AES-CCM (ble_device_base) — no SDK mbedtls/PSA dependency. + if (!ble_device_base::aes_ccm_auth_decrypt(this->bindkey_, nonce.data(), nonce.size(), nullptr, 0, ciphertext, + ciphertext_size, payload.data(), mic, BTHOME_MIC_SIZE)) { + ESP_LOGVV(TAG, "BTHome decryption failed."); return false; } #endif return true; } -bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device) { +bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device) { if (!service_data.uuid.contains(0xD2, 0xFC)) { return false; } @@ -434,5 +435,3 @@ bool BTHomeMiThermometer::handle_service_data_(const esp32_ble_tracker::ServiceD } } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/bthome_ble.h b/esphome/components/bthome_mithermometer/bthome_ble.h index 9bec8ba7a1..4a95311557 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.h +++ b/esphome/components/bthome_mithermometer/bthome_ble.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" @@ -8,11 +8,12 @@ #include #include -#ifdef USE_ESP32 - +// No platform #ifdef: ble_device_base provides the BLE types on every platform; this +// component is only compiled when configured (which requires a BLE hub). bindkey (AES-CCM) +// decryption availability is selected per platform in the .cpp. namespace esphome::bthome_mithermometer { -class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BTHomeMiThermometer final : public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { this->address_ = address; } void set_bindkey(std::initializer_list bindkey); @@ -24,11 +25,11 @@ class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, publi void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } void dump_config() override; - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; protected: - bool handle_service_data_(const esp32_ble_tracker::ServiceData &service_data, - const esp32_ble_tracker::ESPBTDevice &device); + bool handle_service_data_(const ble_device_base::ServiceData &service_data, + const ble_device_base::ESPBTDevice &device); bool decrypt_bthome_payload_(const std::vector &data, uint64_t source_address, std::vector &payload) const; @@ -45,5 +46,3 @@ class BTHomeMiThermometer : public esp32_ble_tracker::ESPBTDeviceListener, publi }; } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 9b50866db0..f559d0aa9b 100644 --- a/esphome/components/bthome_mithermometer/sensor.py +++ b/esphome/components/bthome_mithermometer/sensor.py @@ -20,12 +20,13 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType from . import bthome_mithermometer_base_schema, setup_bthome_mithermometer -CODEOWNERS = ["@nagyrobi"] +AUTO_LOAD = ["ble_device_base"] -DEPENDENCIES = ["esp32_ble_tracker"] +CODEOWNERS = ["@nagyrobi"] CONFIG_SCHEMA = bthome_mithermometer_base_schema( { @@ -67,7 +68,7 @@ CONFIG_SCHEMA = bthome_mithermometer_base_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await setup_bthome_mithermometer(var, config) diff --git a/esphome/components/button/__init__.py b/esphome/components/button/__init__.py index dd4fde5705..ee24002b8a 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_RESTART, DEVICE_CLASS_UPDATE, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -50,7 +51,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({}), } ) @@ -86,7 +89,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("button") -async def setup_button_core_(var, config): +async def setup_button_core_(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) setup_device_class(config) @@ -99,7 +102,7 @@ async def setup_button_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_button(var, config): +async def register_button(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("button", config) @@ -107,7 +110,7 @@ async def register_button(var, config): await setup_button_core_(var, config) -async def new_button(config, *args): +async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_button(var, config) return var @@ -123,11 +126,16 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id( @automation.register_action( "button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True ) -async def button_press_to_code(config, action_id, template_arg, args): +async def button_press_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(button_ns.using) 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/camera.h b/esphome/components/camera/camera.h index bf80b42e54..433361d298 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -103,7 +103,8 @@ struct CameraImageSpec { /** Abstract camera base class. Collaborates with API. * 1) API server starts and registers as a listener (add_listener) * to receive new images from the camera. - * 2) New API client connects and creates a new image reader (create_image_reader). + * 2) API connection creates an image reader (create_image_reader) when it receives + * the first image it will send. * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index a0c59a517a..344248fcf3 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], 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/__init__.py b/esphome/components/canbus/__init__.py index fcd342ad38..b7de235dd1 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,10 +1,13 @@ import re +from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@mvturnho", "@danielschramm"] IS_PLATFORM_COMPONENT = True @@ -18,7 +21,7 @@ CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" -def validate_id(config): +def validate_id(config: ConfigType) -> ConfigType: if CONF_CAN_ID in config: can_id = config[CONF_CAN_ID] id_ext = config[CONF_USE_EXTENDED_ID] @@ -27,7 +30,7 @@ def validate_id(config): return config -def validate_raw_data(value): +def validate_raw_data(value: Any) -> bytes | list: if isinstance(value, str): return value.encode("utf-8") if isinstance(value, list): @@ -71,7 +74,7 @@ CAN_SPEEDS = { } -def get_rate(value): +def get_rate(value: str) -> int: match = re.match(r"(\d+)(?:K(\d+)?)?BPS", value, re.IGNORECASE) if not match: raise ValueError(f"Invalid rate format: {value}") @@ -103,7 +106,7 @@ CANBUS_SCHEMA = cv.Schema( CANBUS_SCHEMA.add_extra(validate_id) -async def setup_canbus_core_(var, config): +async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_can_id([config[CONF_CAN_ID]])) cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) @@ -134,7 +137,7 @@ async def setup_canbus_core_(var, config): ) -async def register_canbus(var, config): +async def register_canbus(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) await setup_canbus_core_(var, config) @@ -157,7 +160,12 @@ async def register_canbus(var, config): ), synchronous=True, ) -async def canbus_action_to_code(config, action_id, template_arg, args): +async def canbus_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) 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/__init__.py b/esphome/components/cap1188/__init__.py index cde9dd46ae..eff0a05163 100644 --- a/esphome/components/cap1188/__init__.py +++ b/esphome/components/cap1188/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RESET_PIN +from esphome.types import ConfigType CONF_TOUCH_THRESHOLD = "touch_threshold" CONF_ALLOW_MULTIPLE_TOUCHES = "allow_multiple_touches" @@ -32,7 +33,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_touch_threshold(config[CONF_TOUCH_THRESHOLD])) cg.add(var.set_allow_multiple_touches(config[CONF_ALLOW_MULTIPLE_TOUCHES])) diff --git a/esphome/components/cap1188/binary_sensor.py b/esphome/components/cap1188/binary_sensor.py index b7af53638a..21fd98ed41 100644 --- a/esphome/components/cap1188/binary_sensor.py +++ b/esphome/components/cap1188/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_CHANNEL +from esphome.types import ConfigType from . import CONF_CAP1188_ID, CAP1188Component, cap1188_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CAP1188Channel).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) hub = await cg.get_variable(config[CONF_CAP1188_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) 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..e490d89062 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,7 +1,7 @@ import logging import esphome.codegen as cg -from esphome.components import web_server_base +from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -13,7 +13,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) @@ -54,14 +54,14 @@ CONFIG_SCHEMA = cv.All( PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_LN882X, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, ] ), ) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() wifi_conf = full_config.get("wifi") @@ -88,24 +88,25 @@ def _final_validate(config: ConfigType) -> ConfigType: socket.consume_sockets(3, "captive_portal")(config) socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) - return config - FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) cg.add_define("USE_CAPTIVE_PORTAL") + # The portal reads wifi scan results from the web server task; this makes the + # wifi component guard them with a lock on multi-threaded platforms. + wifi.request_wifi_scan_results_lock() 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_index.h b/esphome/components/captive_portal/captive_index.h index a81edc1900..84d79a6c66 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,145 +7,146 @@ namespace esphome::captive_portal { #ifdef USE_CAPTIVE_PORTAL_GZIP constexpr uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, - 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, - 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, - 0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a, - 0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68, - 0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5, - 0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22, - 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52, - 0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06, - 0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a, - 0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0, - 0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84, - 0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7, - 0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05, - 0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6, - 0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0, - 0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7, - 0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b, - 0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e, - 0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34, - 0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b, - 0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1, - 0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37, - 0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac, - 0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3, - 0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68, - 0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc, - 0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c, - 0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93, - 0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c, - 0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18, - 0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06, - 0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c, - 0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef, - 0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2, - 0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9, - 0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8, - 0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc, - 0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca, - 0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f, - 0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0, - 0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f, - 0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c, - 0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d, - 0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf, - 0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d, - 0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6, - 0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5, - 0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b, - 0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3, - 0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69, - 0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95, - 0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9, - 0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e, - 0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62, - 0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7, - 0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97, - 0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee, - 0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11, - 0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b, - 0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9, - 0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93, - 0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97, - 0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19, - 0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc, - 0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2, - 0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc, - 0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e, - 0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e, - 0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9, - 0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3, - 0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5, - 0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37, - 0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f, - 0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22, - 0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68, - 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x56, 0x6d, 0x8f, 0xdb, 0x36, 0x0c, 0xfe, 0xbe, + 0x5f, 0xa1, 0x79, 0xdd, 0x6a, 0xaf, 0xb1, 0xfc, 0x92, 0x4b, 0xda, 0x3a, 0x96, 0x8b, 0xee, 0xd6, 0x62, 0x03, 0xd6, + 0xad, 0xc0, 0xdd, 0xba, 0x0f, 0x45, 0x01, 0x2b, 0x32, 0x1d, 0xab, 0x27, 0x4b, 0x9e, 0xa4, 0xbc, 0x35, 0xc8, 0x7e, + 0xfb, 0x20, 0xdb, 0xc9, 0xe5, 0x8a, 0x16, 0xd8, 0x10, 0xc4, 0xa0, 0x44, 0xf2, 0xe1, 0x8b, 0x28, 0x52, 0xf9, 0xb7, + 0x95, 0x62, 0x76, 0xdf, 0x01, 0x6a, 0x6c, 0x2b, 0x8a, 0xdc, 0x7d, 0x91, 0xa0, 0x72, 0x45, 0x40, 0x16, 0x79, 0x03, + 0xb4, 0x2a, 0xf2, 0x16, 0x2c, 0x45, 0xac, 0xa1, 0xda, 0x80, 0x25, 0x7f, 0xde, 0xbe, 0x0e, 0x9f, 0x15, 0xb9, 0xe0, + 0xf2, 0x0e, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0xa8, + 0x22, 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0xc4, 0xdb, 0xf2, 0xca, 0x36, + 0xa4, 0x82, 0x0d, 0x67, 0x10, 0xf6, 0x8b, 0x09, 0x97, 0xdc, 0x72, 0x2a, 0x42, 0xc3, 0xa8, 0x00, 0x92, 0x4c, 0xd6, + 0x06, 0x74, 0xbf, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0xaf, 0xc8, 0x0d, 0xd3, 0xbc, 0xb3, 0xc8, 0xb9, 0x4a, 0x5a, 0x55, + 0xad, 0x05, 0x20, 0xa6, 0x95, 0x31, 0x4a, 0xf3, 0x15, 0x97, 0x45, 0xa5, 0xd8, 0xba, 0x05, 0x69, 0xb1, 0x50, 0x8c, + 0x5a, 0xae, 0x24, 0x36, 0x40, 0x35, 0x6b, 0x08, 0x21, 0xe5, 0x0b, 0x43, 0x37, 0x50, 0xfe, 0xf0, 0x83, 0x7f, 0x16, + 0x5a, 0x81, 0x7d, 0x25, 0xc0, 0x91, 0xe6, 0xa7, 0xfd, 0x2d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x4b, 0x6a, 0x78, 0x05, + 0x65, 0xf0, 0x3e, 0xfe, 0x80, 0x8d, 0xdd, 0x0b, 0xc0, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x52, 0x2e, 0x85, 0x62, 0x77, + 0x65, 0xb0, 0xa8, 0xd7, 0x92, 0x39, 0x70, 0x04, 0x3e, 0x04, 0x07, 0x01, 0x16, 0x49, 0xf2, 0x86, 0xda, 0x06, 0xb7, + 0x74, 0xe7, 0x0f, 0x04, 0x97, 0x7e, 0xfa, 0xa3, 0x0f, 0x4f, 0x92, 0x38, 0x0e, 0x26, 0xfd, 0x27, 0x0e, 0xa2, 0x24, + 0x8e, 0x17, 0x1a, 0xec, 0x5a, 0x4b, 0x64, 0xfd, 0x32, 0xef, 0xa8, 0x6d, 0x50, 0x45, 0xbc, 0x37, 0x49, 0x8a, 0x92, + 0xe7, 0x38, 0x9d, 0xfd, 0x86, 0x9f, 0xa2, 0x2b, 0x9c, 0xce, 0xd8, 0xd3, 0x70, 0x86, 0x92, 0xab, 0x70, 0x86, 0xd2, + 0x14, 0xcf, 0x50, 0xfc, 0xc9, 0x43, 0x35, 0x17, 0x82, 0x78, 0x52, 0x49, 0xf0, 0x90, 0xb1, 0x5a, 0xdd, 0x01, 0xf1, + 0xd8, 0x5a, 0x6b, 0x90, 0xf6, 0x5a, 0x09, 0xa5, 0xbd, 0xa8, 0xf8, 0xe6, 0x7f, 0x01, 0x5a, 0x4d, 0xa5, 0xa9, 0x95, + 0x6e, 0x89, 0xd7, 0xa7, 0xdb, 0x7f, 0x74, 0x90, 0x47, 0xe4, 0x3e, 0xc1, 0x05, 0x33, 0x1c, 0xf2, 0x4a, 0x3c, 0x87, + 0xf8, 0xcc, 0x8b, 0x8a, 0x32, 0x38, 0x9e, 0xa3, 0xb7, 0x2e, 0xfa, 0x31, 0x1e, 0xed, 0xbf, 0x2f, 0x73, 0xb3, 0x59, + 0xa1, 0x5d, 0x2b, 0xa4, 0x21, 0x5e, 0x63, 0x6d, 0x97, 0x45, 0xd1, 0x76, 0xbb, 0xc5, 0xdb, 0x29, 0x56, 0x7a, 0x15, + 0xa5, 0x71, 0x1c, 0x47, 0x66, 0xb3, 0xf2, 0xd0, 0x70, 0xf2, 0x5e, 0x7a, 0xe5, 0xa1, 0x06, 0xf8, 0xaa, 0xb1, 0x3d, + 0x5d, 0x3c, 0x3a, 0xc0, 0x31, 0x77, 0x12, 0x45, 0xf9, 0xe1, 0xc2, 0x8a, 0xbc, 0xb0, 0x02, 0x2f, 0x2e, 0xf2, 0xf6, + 0xb8, 0x0f, 0xf3, 0x29, 0x4d, 0x51, 0x8a, 0xe2, 0xfe, 0x97, 0x86, 0x8e, 0x1e, 0x57, 0xe1, 0x67, 0x2b, 0x74, 0xb1, + 0x72, 0x54, 0x3b, 0x0f, 0x9f, 0x9f, 0x75, 0x13, 0xb7, 0xb3, 0x49, 0xe2, 0xfb, 0x0d, 0xa7, 0xf0, 0xcb, 0xfc, 0x72, + 0x1d, 0xa6, 0xef, 0x2e, 0x05, 0x9c, 0xb5, 0x26, 0x79, 0x37, 0xa7, 0x33, 0x34, 0x1b, 0x77, 0x66, 0xa1, 0xa3, 0xcf, + 0x2b, 0x34, 0xdb, 0xa4, 0x4d, 0xd2, 0x86, 0xf3, 0x70, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xda, + 0xcc, 0xdf, 0xcd, 0x2f, 0xf7, 0xc2, 0xe9, 0xa7, 0xc7, 0x2e, 0xb9, 0x59, 0x59, 0xde, 0x47, 0xae, 0x2f, 0x23, 0xc7, + 0x1f, 0x15, 0x97, 0x7e, 0xe9, 0xf2, 0x0f, 0x96, 0x35, 0x7e, 0x19, 0x31, 0x25, 0x6b, 0xbe, 0xc2, 0x1f, 0x8d, 0x92, + 0x65, 0x80, 0x6d, 0x03, 0xd2, 0x3f, 0xa9, 0xfa, 0x36, 0x38, 0xd8, 0x9e, 0xe3, 0x7f, 0x81, 0x73, 0xae, 0x7f, 0xcb, + 0xad, 0x00, 0x62, 0xb1, 0xbb, 0xa1, 0x93, 0x2f, 0xdc, 0x8a, 0x9f, 0xf6, 0xbf, 0x56, 0x7e, 0xd9, 0x52, 0x56, 0x06, + 0x98, 0x4b, 0x09, 0xfa, 0x16, 0x76, 0x96, 0x94, 0x6f, 0x5e, 0x5e, 0xa3, 0x97, 0x55, 0xa5, 0xc1, 0x98, 0x0c, 0x95, + 0x4f, 0x2c, 0x6e, 0x29, 0xfb, 0xba, 0x7a, 0x93, 0x3c, 0xd4, 0xfe, 0x8b, 0xbf, 0xe6, 0xe8, 0x77, 0xb0, 0x5b, 0xa5, + 0xef, 0x46, 0x7d, 0x67, 0x7f, 0xe1, 0xae, 0x91, 0x26, 0x5f, 0x85, 0x91, 0x60, 0xcb, 0x60, 0xc2, 0xbf, 0x2e, 0x60, + 0x0c, 0xaf, 0xca, 0x60, 0x42, 0xbf, 0x2e, 0xd1, 0x19, 0x77, 0x79, 0x2d, 0xa6, 0x9d, 0xc1, 0x46, 0x70, 0x06, 0x7e, + 0x12, 0xe0, 0x5a, 0xe9, 0x57, 0x94, 0x35, 0x0f, 0x12, 0xe4, 0x5c, 0x51, 0xf7, 0x38, 0x4c, 0x03, 0xb5, 0x30, 0x42, + 0xf9, 0x65, 0xc5, 0x37, 0x65, 0xb0, 0x50, 0x98, 0x09, 0x6a, 0x8c, 0x6b, 0x19, 0xc4, 0x39, 0xe7, 0xc2, 0x29, 0x27, + 0x6a, 0x88, 0xf4, 0x97, 0xdb, 0x37, 0xbf, 0x91, 0x32, 0xa7, 0x43, 0x47, 0xf4, 0xbe, 0xf3, 0x50, 0x2f, 0x4c, 0xbc, + 0x51, 0x30, 0x14, 0x50, 0xdb, 0xbe, 0xe2, 0x7d, 0x8b, 0xb5, 0x31, 0x3c, 0x38, 0xe6, 0xa6, 0xa3, 0xf2, 0x73, 0x31, + 0x17, 0x93, 0x57, 0xe4, 0x91, 0xe3, 0x15, 0x79, 0x44, 0x8b, 0x47, 0x07, 0xe9, 0xf7, 0xcd, 0xed, 0x2e, 0x38, 0x3a, + 0x6b, 0x7f, 0xaf, 0x41, 0xef, 0x6f, 0x40, 0x00, 0xb3, 0x4a, 0xfb, 0x25, 0xbe, 0x54, 0x74, 0x45, 0x01, 0x3b, 0x7b, + 0x3d, 0x36, 0x5c, 0x8b, 0xdd, 0xe6, 0x44, 0x61, 0x25, 0x99, 0xe0, 0xec, 0x8e, 0x9c, 0x23, 0x0e, 0x0e, 0x1c, 0x6f, + 0xa8, 0x58, 0xc3, 0x49, 0x86, 0xe2, 0x5a, 0xb1, 0xb5, 0xf1, 0x83, 0xe3, 0x44, 0x63, 0xda, 0x75, 0x20, 0xab, 0xeb, + 0x86, 0x8b, 0xca, 0x57, 0xc1, 0x31, 0xb8, 0x3f, 0xe9, 0xcf, 0x8c, 0xbb, 0x59, 0xf0, 0x5e, 0x83, 0xf8, 0x87, 0x3c, + 0x76, 0xd3, 0xe0, 0xf1, 0x87, 0x32, 0xc0, 0x7d, 0xfc, 0xe5, 0xfd, 0x48, 0x70, 0xd7, 0xfb, 0xc9, 0xae, 0x15, 0x13, + 0x17, 0x7a, 0x38, 0x9f, 0x05, 0xc7, 0xf2, 0x18, 0x1c, 0x83, 0x45, 0x1e, 0x0d, 0x8d, 0xbd, 0xc8, 0xfb, 0x96, 0xdb, + 0x8f, 0x94, 0x9e, 0x32, 0x0d, 0x80, 0x7d, 0xd0, 0xe2, 0x7f, 0x3c, 0x2c, 0xd5, 0x2e, 0x34, 0xfc, 0x13, 0x97, 0xab, + 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0x26, 0x5c, 0x76, 0x6b, 0x7b, 0xe8, 0x68, 0x55, 0x39, 0xce, 0xac, + 0xdb, 0x2d, 0x6a, 0x25, 0xad, 0x93, 0x84, 0x2c, 0x81, 0xf6, 0x38, 0xf0, 0xfb, 0xe6, 0x93, 0x3d, 0x9f, 0x7d, 0x7f, + 0x5c, 0xaa, 0x6a, 0x7f, 0x70, 0x19, 0x0a, 0xa9, 0xe0, 0x2b, 0x99, 0x31, 0x90, 0x16, 0xf4, 0xa0, 0x54, 0xd3, 0x96, + 0x8b, 0x7d, 0x66, 0xa8, 0x34, 0xa1, 0x01, 0xcd, 0xeb, 0xe3, 0x72, 0x6d, 0xad, 0x92, 0x07, 0xe6, 0x9a, 0x6d, 0xf6, + 0x5d, 0x5d, 0xd7, 0x0b, 0xb6, 0xd6, 0x46, 0xe9, 0xac, 0x53, 0xbc, 0xd7, 0x5b, 0x52, 0x76, 0xb7, 0xd2, 0x6a, 0x2d, + 0xab, 0x70, 0x14, 0x4a, 0x6a, 0x3a, 0x05, 0xb6, 0x58, 0x2a, 0x5d, 0x81, 0xce, 0xe2, 0x91, 0x08, 0x35, 0xad, 0xf8, + 0xda, 0x64, 0x78, 0xaa, 0xa1, 0x5d, 0x0c, 0xee, 0x24, 0x71, 0xfc, 0xfd, 0xe2, 0xe4, 0x79, 0x7c, 0xe9, 0x37, 0x4e, + 0x9d, 0x94, 0xe0, 0x12, 0xc2, 0xa1, 0x57, 0x66, 0x29, 0xbe, 0xd2, 0xd0, 0x1e, 0x5b, 0xca, 0xe5, 0xa5, 0xf7, 0xae, + 0xa2, 0x16, 0x2d, 0x97, 0xc3, 0x28, 0xcd, 0xd2, 0x79, 0xdc, 0xed, 0x16, 0xe3, 0xe4, 0xca, 0xb8, 0xec, 0x11, 0xfa, + 0xf9, 0x75, 0x3c, 0x15, 0xc9, 0xe1, 0xe3, 0xda, 0x58, 0x5e, 0xef, 0xc3, 0x71, 0x24, 0x67, 0xa6, 0xa3, 0x0c, 0xc2, + 0x25, 0xd8, 0x2d, 0x80, 0x5c, 0xf4, 0xb0, 0x21, 0xb7, 0xd0, 0x9a, 0x53, 0x6a, 0x4e, 0x70, 0xb5, 0x80, 0xdd, 0x19, + 0xa6, 0xaf, 0xe5, 0xc3, 0x7f, 0x96, 0x76, 0x05, 0x76, 0x68, 0xa9, 0x5e, 0x71, 0x19, 0x2e, 0x95, 0xb5, 0xaa, 0xcd, + 0xc2, 0xa7, 0xdd, 0x6e, 0x31, 0x6e, 0x39, 0xb0, 0x2c, 0x89, 0xbb, 0xdd, 0xb1, 0x1f, 0xc3, 0xa7, 0x7c, 0x5f, 0xd5, + 0xcf, 0x68, 0x12, 0x7f, 0x21, 0xc7, 0x55, 0x5d, 0xa7, 0xcb, 0xfa, 0x94, 0xe3, 0xa4, 0xdb, 0x21, 0xa3, 0x04, 0xaf, + 0x46, 0xb8, 0x1e, 0x09, 0xc5, 0xe7, 0xd4, 0x26, 0xb3, 0x6e, 0x87, 0x92, 0xcb, 0xcc, 0xb8, 0x89, 0xea, 0xa6, 0x8e, + 0xab, 0xb5, 0x22, 0x8f, 0x86, 0x97, 0x8e, 0xab, 0x8c, 0x22, 0x77, 0x19, 0x2e, 0xf2, 0x26, 0x41, 0xbc, 0x22, 0x2d, + 0x65, 0xc5, 0x45, 0xdb, 0xcb, 0xa3, 0x26, 0x39, 0xb1, 0x9a, 0xa4, 0x78, 0xd0, 0xd2, 0x06, 0x5e, 0xef, 0x7d, 0x71, + 0xad, 0xa4, 0x04, 0x66, 0xb9, 0x5c, 0x21, 0xab, 0xd0, 0x98, 0x02, 0x8c, 0x71, 0xbe, 0xd4, 0xc5, 0x5b, 0x01, 0xd4, + 0x00, 0xda, 0x52, 0x6e, 0x71, 0x1e, 0x0d, 0xf2, 0x43, 0x13, 0xe0, 0x15, 0x91, 0x60, 0xcf, 0xd7, 0xbe, 0x99, 0x0e, + 0x06, 0x6e, 0xc0, 0x3a, 0x24, 0x67, 0x60, 0x5a, 0xe4, 0x6e, 0x3a, 0x23, 0xda, 0x5f, 0x60, 0x12, 0x6d, 0x79, 0xcd, + 0xdd, 0xeb, 0xa6, 0xc8, 0xfb, 0x22, 0x77, 0x08, 0x2e, 0xcf, 0xc3, 0xd3, 0xab, 0xa7, 0x04, 0xc8, 0x95, 0x6d, 0xc8, + 0x34, 0x45, 0x9d, 0xa0, 0x0c, 0x1a, 0x25, 0x2a, 0xd0, 0xe4, 0xe6, 0xe6, 0xd7, 0x9f, 0x0b, 0xe7, 0xcc, 0xbd, 0x5e, + 0x67, 0xee, 0x06, 0x35, 0x47, 0x8c, 0x5a, 0xf3, 0xab, 0xe1, 0xc1, 0xd5, 0x51, 0x63, 0xb6, 0x4a, 0x57, 0x0f, 0x30, + 0xde, 0x8e, 0x9b, 0x03, 0x4e, 0xff, 0xef, 0xaf, 0x4a, 0x71, 0x43, 0x37, 0x90, 0x47, 0xe3, 0x22, 0x8f, 0x9c, 0xc3, + 0x03, 0xbf, 0x19, 0xe5, 0x9a, 0xa4, 0xf8, 0xe3, 0xf6, 0x25, 0xfa, 0xb3, 0xab, 0xa8, 0x85, 0x21, 0x6d, 0x7d, 0x54, + 0x2d, 0xd8, 0x46, 0x55, 0xe4, 0xed, 0x1f, 0x37, 0xb7, 0xe7, 0x08, 0xd7, 0xbd, 0x10, 0x02, 0xc9, 0x86, 0xa7, 0xdf, + 0x5a, 0x58, 0xde, 0x51, 0x6d, 0x7b, 0xd8, 0xd0, 0x35, 0x98, 0x53, 0x0c, 0x3d, 0xbf, 0xe6, 0x02, 0x86, 0x30, 0x06, + 0xc5, 0x02, 0x9d, 0xbc, 0x3a, 0x59, 0xfb, 0xcc, 0xaf, 0x68, 0x38, 0xed, 0x68, 0x38, 0xfa, 0xa8, 0x7f, 0x05, 0xff, + 0x0b, 0x54, 0xcf, 0x54, 0x8b, 0x15, 0x0b, 0x00, 0x00}; #else // Brotli (default, smaller) constexpr uint8_t INDEX_BR[] PROGMEM = { - 0x1b, 0xf8, 0x0a, 0x00, 0x64, 0x5a, 0xd3, 0xfa, 0xe7, 0xf3, 0x62, 0xd8, 0x06, 0x1b, 0xe9, 0x6a, 0x8a, 0x81, 0x2b, - 0xb5, 0x49, 0x14, 0x37, 0xdc, 0x9e, 0x1a, 0xcb, 0x56, 0x87, 0xfb, 0xff, 0xf7, 0x73, 0x75, 0x12, 0x0a, 0xd6, 0x48, - 0x84, 0xc6, 0x21, 0xa4, 0x6d, 0xb5, 0x71, 0xef, 0x13, 0xbe, 0x4e, 0x54, 0xf1, 0x64, 0x8f, 0x3f, 0xcc, 0x9a, 0x78, - 0xa5, 0x89, 0x25, 0xb3, 0xda, 0x2c, 0xa2, 0x32, 0x9c, 0x57, 0x07, 0x56, 0xbc, 0x34, 0x13, 0xff, 0x5c, 0x0a, 0xa1, - 0x67, 0x82, 0xb8, 0x6b, 0x4c, 0x76, 0x31, 0x6c, 0xe3, 0x40, 0x46, 0xea, 0xb0, 0xd4, 0xf4, 0x3b, 0x02, 0x65, 0x18, - 0xa4, 0xaf, 0xac, 0x6d, 0x55, 0xd6, 0xbe, 0x59, 0x66, 0x7a, 0x7c, 0x60, 0xb2, 0x83, 0x33, 0x23, 0xc9, 0x79, 0x82, - 0x47, 0xb4, 0x28, 0xf4, 0x24, 0xb5, 0x23, 0x5a, 0x44, 0xe1, 0xc3, 0x27, 0x04, 0xe8, 0x0c, 0xdd, 0xb4, 0xd0, 0x8c, - 0xfb, 0x10, 0x39, 0x93, 0x04, 0x2a, 0x66, 0x18, 0x4b, 0x74, 0xca, 0x31, 0x7f, 0xb2, 0xe5, 0x45, 0xc1, 0xdd, 0x72, - 0x49, 0xff, 0x0e, 0xb3, 0xf0, 0x93, 0x18, 0xab, 0x68, 0xad, 0xe1, 0x9d, 0xe4, 0x29, 0xc0, 0xe3, 0x63, 0x54, 0x61, - 0x1b, 0x45, 0xb9, 0x6c, 0x23, 0x0f, 0x99, 0x7f, 0x8e, 0x69, 0xaa, 0xc1, 0xb8, 0x4e, 0x42, 0x9c, 0xc5, 0x6e, 0x69, - 0x40, 0x0e, 0x4f, 0x97, 0xd3, 0x23, 0x18, 0xf5, 0xc8, 0x75, 0x73, 0xb5, 0xbd, 0x46, 0x8a, 0x97, 0x7d, 0x83, 0xe4, - 0x29, 0x72, 0x73, 0xc1, 0x39, 0x8e, 0x7e, 0x84, 0x39, 0x66, 0x57, 0xc6, 0x85, 0x19, 0x8b, 0xf2, 0x4d, 0xd9, 0xfe, - 0x75, 0xa9, 0xe1, 0x2b, 0x21, 0x81, 0x58, 0x51, 0x99, 0xbc, 0xa4, 0x0b, 0x10, 0x6f, 0x86, 0x17, 0x0b, 0x92, 0x00, - 0x11, 0x6f, 0x3b, 0xa4, 0xa4, 0x11, 0x7e, 0x0b, 0x97, 0x85, 0x23, 0x0c, 0x01, 0x6f, 0x2a, 0x18, 0xc6, 0xbe, 0x3d, - 0x77, 0x1a, 0xe6, 0x00, 0x5c, 0x1a, 0x14, 0x47, 0xc6, 0xcc, 0xcc, 0x52, 0xbe, 0x04, 0x19, 0x31, 0x05, 0x46, 0xa0, - 0xc3, 0x69, 0x0c, 0x60, 0xb7, 0x14, 0x57, 0xa0, 0x92, 0xbf, 0xb7, 0x0c, 0xd8, 0x3a, 0x79, 0x09, 0x99, 0xc9, 0x71, - 0x88, 0x01, 0x8b, 0xa5, 0x61, 0x0a, 0xb5, 0xe8, 0xc7, 0x71, 0xe7, 0x70, 0x79, 0xb6, 0xe4, 0x01, 0xfc, 0x1a, 0x4a, - 0x7b, 0x60, 0x6e, 0xef, 0x95, 0x62, 0x59, 0x28, 0xb5, 0x25, 0x56, 0x15, 0xe7, 0xca, 0xad, 0x32, 0xe6, 0xf7, 0x01, - 0x31, 0x34, 0x87, 0x93, 0x0b, 0x9b, 0x9d, 0x26, 0xff, 0xe5, 0x92, 0xad, 0x6f, 0xb8, 0x3b, 0x16, 0xc1, 0xa0, 0x5a, - 0x4f, 0x52, 0x0b, 0x2b, 0xc1, 0xa7, 0x95, 0x7b, 0x24, 0x51, 0xd3, 0xb3, 0x23, 0x62, 0x0b, 0xcc, 0xa0, 0x58, 0xa7, - 0x64, 0x45, 0x2f, 0x0b, 0xdd, 0x1d, 0x97, 0x82, 0x1f, 0xcc, 0x64, 0xdb, 0xd3, 0xf4, 0xb0, 0x8b, 0xc8, 0xcf, 0x15, - 0x81, 0x8b, 0xa1, 0x9d, 0xf8, 0xfc, 0xec, 0x49, 0x40, 0x12, 0x01, 0x09, 0x51, 0xf3, 0x73, 0x18, 0x24, 0x97, 0x55, - 0x85, 0x6a, 0x92, 0x1a, 0xf5, 0x5a, 0x05, 0x54, 0x1f, 0x27, 0x0a, 0xa8, 0xa1, 0x94, 0x58, 0x78, 0x7d, 0x87, 0xa8, - 0xdb, 0x13, 0x66, 0x20, 0x5e, 0x43, 0x18, 0x7a, 0xbb, 0x16, 0x16, 0x07, 0xc8, 0xab, 0x10, 0xe2, 0x50, 0xb9, 0xb1, - 0xd8, 0x21, 0xc8, 0x4a, 0x2e, 0x99, 0x0e, 0x23, 0x52, 0xc6, 0xcb, 0x29, 0x84, 0x91, 0x03, 0xb1, 0xe2, 0x4c, 0x1d, - 0x22, 0xd3, 0xc8, 0x79, 0x00, 0x8b, 0x8b, 0x88, 0x1e, 0x29, 0xd3, 0xae, 0x10, 0x15, 0x22, 0x6d, 0xb0, 0x87, 0x6f, - 0x27, 0x2e, 0x7c, 0xc2, 0x7a, 0x61, 0xbd, 0x22, 0xe5, 0x5f, 0xdd, 0x7b, 0x00, 0x04, 0xf2, 0x7d, 0x5a, 0x03, 0x38, - 0x1f, 0x69, 0x6d, 0x0b, 0xfb, 0xec, 0x45, 0xfe, 0x8b, 0x7f, 0xec, 0x7b, 0xad, 0xc2, 0x33, 0xf1, 0x9e, 0x9c, 0x71, - 0xd9, 0xe8, 0x5e, 0x8f, 0xd4, 0xee, 0x87, 0x45, 0x6c, 0xe2, 0x12, 0xf8, 0xb8, 0xc5, 0xee, 0x43, 0xa6, 0x37, 0x91, - 0xb5, 0x2c, 0x2f, 0xe9, 0xe8, 0x24, 0xd0, 0x45, 0xc1, 0x0c, 0x7c, 0xf0, 0xb2, 0xb5, 0x2d, 0x10, 0x36, 0x7e, 0x18, - 0x7c, 0x79, 0x82, 0x69, 0x3d, 0x35, 0xca, 0x52, 0xee, 0xc9, 0xb5, 0x65, 0xa4, 0xa1, 0xfd, 0x70, 0x7e, 0xe0, 0x7d, - 0x67, 0xf9, 0xa1, 0x71, 0xd2, 0x08, 0x74, 0x33, 0x5f, 0x69, 0xa4, 0x59, 0x03, 0xfd, 0xf8, 0xf0, 0x70, 0x1a, 0x50, - 0x43, 0xfb, 0x61, 0xf0, 0x38, 0x18, 0x88, 0x85, 0x36, 0x23, 0x06, 0x4f, 0x02, 0xbb, 0x78, 0x1a, 0xaa, 0xd2, 0x02, - 0x5e, 0xa0, 0x74, 0x30, 0xc8, 0x7a, 0x66, 0xab, 0xd9, 0x43, 0x99, 0x45, 0xb7, 0x0c, 0x5c, 0xec, 0xc8, 0x03, 0x0e, - 0x0b, 0xca, 0x4a, 0x22, 0x48, 0xfb, 0xb7, 0x3d, 0x82, 0x07, 0x8d, 0x1b, 0x21, 0x87, 0x4d, 0x57, 0xa4, 0x5b, 0xd4, - 0xe3, 0x88, 0x02, 0xc4, 0x81, 0xf9, 0x47, 0xe4, 0xbf, 0x3e, 0x39, 0xbb, 0x4f, 0x7e, 0x91, 0x63, 0x98, 0x97, 0xe4, - 0x52, 0x01, 0x58, 0xba, 0x32, 0xbf, 0xae, 0xff, 0x45, 0xa1, 0xbc, 0x9b, 0xa4, 0x09, 0x0e, 0x79, 0xc0, 0x41, 0x86, - 0x52, 0x88, 0x55, 0x39, 0x9d, 0xb6, 0xed, 0x35, 0x68, 0x29, 0xfa, 0xe6, 0x6c, 0x3d, 0x0a, 0xcd, 0x6a, 0x28, 0xfd, - 0x65, 0x24, 0xce, 0x38, 0x98, 0x01, 0xd9, 0x3f, 0x1b, 0x4c, 0xc4, 0x5c, 0x1d, 0xaa, 0x21, 0x78, 0x67, 0xaf, 0x55, - 0x72, 0x34, 0xf8, 0x1b, 0x03, 0x21, 0x27, 0x08, 0xbd, 0x59, 0x60, 0x48, 0x0d, 0xe2, 0x56, 0x9b, 0x30, 0x92, 0x8f, - 0x67, 0x8a, 0x7f, 0x20, 0xbd, 0x2d, 0xfd, 0xc5, 0xb0, 0xa6, 0xaa, 0x77, 0x75, 0x26, 0x33, 0x2f, 0x20, 0x2a, 0xab, - 0x5c, 0xd1, 0x3b, 0xda, 0xb2, 0x4c, 0xa4, 0x86, 0x25, 0x8d, 0x49, 0x05, 0xaf, 0x7a, 0xa8, 0xd4, 0x9c, 0x0d, 0xd3, - 0x38, 0xa6, 0x5c, 0x29, 0x6b, 0x16, 0x27, 0x07, 0xf1, 0xbe, 0xe2, 0x24, 0xc1, 0x8d, 0x25, 0x76, 0xbc, 0xf6, 0x0d, - 0xc2, 0x94, 0x25, 0xb8, 0xf3, 0x07, 0x9a, 0x49, 0xf4, 0x89, 0x82, 0x4d, 0x51, 0xb1, 0x96, 0x61, 0x62, 0x8d, 0xc8, - 0x61, 0x65, 0x0d, 0x14, 0x34, 0x02, 0x65, 0x94, 0xcc, 0x1d, 0x85, 0x00, 0x0f, 0x1a, 0x57, 0x68, 0x15, 0xcf, 0xa4, - 0xa2, 0x7d, 0x6d, 0x53, 0x60, 0xce, 0x5c, 0x61, 0x82, 0x17, 0x32, 0xc1, 0x87, 0x02, 0x0c, 0x91, 0x85, 0x57, 0x51, - 0xbe, 0xb2, 0x38, 0x9f, 0x3d, 0x2a, 0x52, 0x5a, 0xad, 0xba, 0x46, 0x9e, 0x3c, 0x8a, 0xa0, 0x46, 0x15, 0xf4, 0x59, - 0x74, 0x5f, 0x2a, 0xae, 0x96, 0x56, 0xf0, 0x54, 0x39, 0xaf, 0xac, 0x2a, 0xb9, 0xad, 0x32, 0x50, 0xc9, 0xc1, 0xee, - 0xd2, 0x0d, 0x34, 0xaa, 0x98, 0x4d, 0x6d, 0x3d, 0xc6, 0xb9, 0x5b, 0x00, 0x5f, 0xea, 0xda, 0x16, 0xa6, 0x08, 0x43, - 0x58, 0x4d, 0x8d, 0x07, 0x55, 0x62, 0x81, 0x44, 0xcc, 0x31, 0x04, 0x4b, 0x4c, 0x8b, 0x3e, 0xff, 0xd8, 0xf6, 0x65, - 0x19, 0xa1, 0x94, 0x62, 0x65, 0x0a, 0xdd, 0x60, 0x38, 0xd3, 0xbe, 0x0d, 0xa3, 0x99, 0xd5, 0x37, 0x68, 0xa1, 0x71, - 0xa3, 0x41, 0xe7, 0xbe, 0x9d, 0x72, 0x84, 0x75, 0xb6, 0x8d, 0x98, 0xd6, 0xb8, 0x2d, 0x43, 0x85, 0x5d, 0xf9, 0xca, - 0xc3, 0x96, 0xa5, 0xa6, 0xe7, 0x50, 0x88, 0x6b, 0x84, 0x58, 0x44, 0x45, 0x20, 0xdf, 0x1e, 0x5a, 0xc9, 0xce, 0x42, - 0x2a, 0x1f, 0x3e, 0x3c, 0x7b, 0x68, 0x3c, 0x34, 0x8b, 0x36, 0xba, 0x1f, 0xce, 0x0f, 0xa0, 0x60, 0x37, 0x5f, 0x1a, - 0x03, 0x2b, 0x86, 0x29, 0x45, 0x7b, 0xb4, 0xb7, 0x06, 0x68, 0x17, 0x7e, 0x13, 0x76, 0x91, 0x4d, 0x27, 0xee, 0xbc, - 0x7e, 0x80, 0xc2, 0x66, 0xac, 0xc6, 0xbf, 0xeb, 0x7f, 0xd7, 0x84, 0x79, 0xf3, 0xf1, 0xde, 0xec, 0xa6, 0x93, 0xa8, - 0x13, 0x3b, 0x4a, 0x81, 0xfa, 0x11, 0x1e, 0x4a, 0xd2, 0x50, 0x2a, 0xea, 0x9a, 0xc2, 0x37, 0x08, 0xed, 0x01, 0xf5, - 0xa2, 0xd5, 0x32, 0x29, 0x49, 0xc4, 0x1a, 0x11, 0xc0, 0xda, 0x24, 0x28, 0x84, 0x38, 0x60, 0x80, 0xcf, 0xd0, 0x45, - 0x83, 0xa7, 0xca, 0x52, 0x5c, 0xac, 0x23, 0x01}; + 0x1b, 0x14, 0x0b, 0x00, 0xe4, 0x6f, 0xcd, 0xfc, 0x7b, 0x2e, 0x27, 0xd2, 0x21, 0x2b, 0x58, 0xea, 0x16, 0xf8, 0xa5, + 0xb6, 0x47, 0x14, 0xb3, 0x4c, 0x56, 0xcc, 0x20, 0x5b, 0x1d, 0xfe, 0x7d, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6, 0x48, + 0x84, 0xa2, 0x21, 0x69, 0x0d, 0x77, 0x33, 0xf3, 0x77, 0xcf, 0x4c, 0x21, 0xf1, 0xde, 0xee, 0x7d, 0x44, 0xbc, 0xd3, + 0xc4, 0x33, 0x89, 0x1a, 0x69, 0x68, 0x48, 0x57, 0xa7, 0x17, 0x0b, 0x4d, 0x91, 0xac, 0x30, 0x48, 0x21, 0x4d, 0x4c, + 0x10, 0x57, 0x46, 0x14, 0x17, 0xd5, 0x21, 0x0e, 0xb4, 0x50, 0x87, 0xad, 0x62, 0xda, 0x11, 0x68, 0xc3, 0x20, 0x7f, + 0x2d, 0xdc, 0x57, 0xeb, 0x2c, 0x36, 0xdb, 0xc4, 0x84, 0x0f, 0xac, 0xec, 0x08, 0x91, 0xa3, 0x92, 0x27, 0x45, 0xc4, + 0x1e, 0xa4, 0x9e, 0x72, 0x3b, 0xc2, 0xe3, 0x20, 0x7d, 0xf8, 0x05, 0x09, 0xea, 0xe3, 0xa6, 0x3f, 0x11, 0x8b, 0x16, + 0x11, 0x6b, 0x26, 0x91, 0x1a, 0x2c, 0x19, 0x24, 0xf7, 0x5d, 0x44, 0x82, 0x49, 0x44, 0x15, 0xce, 0x39, 0xdc, 0xcb, + 0x8f, 0x5b, 0xe1, 0xe2, 0x42, 0x96, 0xa2, 0x10, 0x3c, 0x20, 0xa5, 0x65, 0x85, 0x49, 0x6a, 0x06, 0x08, 0xd1, 0xcb, + 0x41, 0xb8, 0x49, 0x20, 0x73, 0x71, 0xfe, 0x55, 0x61, 0x45, 0xc6, 0x95, 0x72, 0xc8, 0xf0, 0xa5, 0xe9, 0xe6, 0x3a, + 0xb9, 0xc3, 0x8a, 0x9f, 0xb5, 0xc1, 0xc9, 0x15, 0x56, 0x93, 0x38, 0x8a, 0x48, 0xf0, 0x4f, 0x38, 0x22, 0xe1, 0x8d, + 0x55, 0xbb, 0x8c, 0xc3, 0xb0, 0x68, 0xcc, 0x7f, 0x6f, 0xf8, 0xc9, 0x9b, 0x38, 0x41, 0xf1, 0x94, 0x25, 0xf9, 0x6b, + 0x56, 0xa2, 0xec, 0xa7, 0xbd, 0x2e, 0x69, 0x8e, 0xe2, 0xec, 0x36, 0x94, 0x24, 0x2c, 0x12, 0x1d, 0x4e, 0x76, 0x7e, + 0x23, 0xcc, 0xf2, 0x6e, 0x35, 0x1a, 0x9c, 0xed, 0x6f, 0x15, 0x3f, 0xc9, 0x72, 0xdc, 0xfd, 0x13, 0x0f, 0x16, 0x8a, + 0x23, 0x4f, 0xf9, 0x2e, 0x63, 0x44, 0x91, 0x77, 0xe0, 0xb3, 0xd1, 0x78, 0x74, 0xdb, 0x4a, 0x2c, 0xd8, 0xe8, 0xf9, + 0x2c, 0x03, 0xbe, 0xae, 0xac, 0x4e, 0x42, 0x01, 0xc4, 0x4b, 0x40, 0xe7, 0x94, 0x34, 0x85, 0x2c, 0xfe, 0xf5, 0x74, + 0x75, 0xd8, 0xdc, 0xec, 0x6a, 0x00, 0x3f, 0x3f, 0x81, 0x77, 0xa8, 0xcd, 0xde, 0x6d, 0x5a, 0x47, 0xa1, 0x99, 0x36, + 0x87, 0xb6, 0x78, 0x35, 0x3c, 0x9a, 0x64, 0x15, 0x7c, 0x56, 0x76, 0x22, 0xce, 0x46, 0xe5, 0x17, 0x57, 0x05, 0xfc, + 0x09, 0x69, 0xae, 0x39, 0xaa, 0xee, 0xc9, 0x4e, 0x7f, 0x99, 0x2a, 0x65, 0x82, 0x7e, 0xeb, 0x23, 0x4f, 0x42, 0xd5, + 0xca, 0xcb, 0x42, 0x74, 0x2e, 0xd2, 0xa2, 0x62, 0x57, 0xd0, 0xa9, 0x7b, 0x4b, 0xac, 0x7b, 0x65, 0x13, 0x47, 0x0f, + 0x2d, 0x3d, 0xf6, 0xbc, 0x78, 0x5c, 0xa3, 0xc9, 0x57, 0x4b, 0x10, 0x62, 0x68, 0x19, 0x7f, 0xfd, 0x1a, 0xcb, 0x51, + 0x1e, 0x41, 0x39, 0x55, 0xf3, 0x97, 0x30, 0xca, 0x37, 0xb6, 0x42, 0x1d, 0x2d, 0x8c, 0xc6, 0x65, 0x8a, 0xd2, 0x89, + 0x88, 0xa6, 0x28, 0xbd, 0x56, 0x7c, 0x2d, 0x5e, 0x0d, 0x2f, 0x9e, 0xc3, 0xa5, 0x80, 0xaf, 0xcc, 0x00, 0xde, 0xe6, + 0x5a, 0x8b, 0xda, 0xff, 0x1f, 0x16, 0xc8, 0x83, 0x5e, 0xe5, 0xea, 0x25, 0x86, 0x70, 0x55, 0x25, 0x01, 0x14, 0x0c, + 0x77, 0xd8, 0x31, 0x21, 0xcc, 0x79, 0x15, 0x3b, 0x32, 0x3a, 0xd3, 0xc5, 0x30, 0xaf, 0x03, 0xd8, 0x3e, 0x44, 0xb8, + 0x63, 0xb5, 0x74, 0x4f, 0x41, 0x4b, 0xf0, 0x24, 0x74, 0xb2, 0x06, 0xb2, 0x7b, 0x06, 0x60, 0xdc, 0xc1, 0x3e, 0x0e, + 0x6f, 0x1e, 0x3c, 0x42, 0xa0, 0xdb, 0x36, 0x43, 0x30, 0x71, 0xcc, 0xd6, 0x1a, 0xbd, 0x38, 0x61, 0x19, 0x3f, 0xf2, + 0xdf, 0xf4, 0x53, 0x3d, 0xc1, 0x14, 0xbe, 0x50, 0x1c, 0x2c, 0xf3, 0xaa, 0x74, 0x36, 0xcb, 0xbd, 0x7a, 0x4b, 0xa3, + 0x1c, 0x90, 0x40, 0x5b, 0x4a, 0x0f, 0x83, 0x6e, 0x9e, 0x96, 0x28, 0x3d, 0x77, 0x43, 0x05, 0x0e, 0x39, 0x26, 0x15, + 0xb8, 0x6b, 0x4e, 0x3a, 0x62, 0xc2, 0xda, 0xde, 0x8e, 0x29, 0x29, 0x0b, 0x09, 0xa2, 0xb3, 0xa7, 0x1e, 0x9a, 0x0c, + 0x8f, 0xa1, 0x46, 0x6f, 0x92, 0xf3, 0x9e, 0xb5, 0xc5, 0x7e, 0x0e, 0x8d, 0xeb, 0x55, 0x08, 0xfa, 0xc9, 0xd8, 0x4e, + 0xe3, 0xd0, 0xca, 0x2a, 0x96, 0x11, 0x7e, 0xb1, 0xd4, 0x7f, 0x8f, 0x1d, 0xb3, 0xc3, 0xa0, 0x89, 0x6f, 0x93, 0x99, + 0x55, 0x48, 0x97, 0x08, 0x79, 0x66, 0xe9, 0x4a, 0x6b, 0xa0, 0x29, 0xf2, 0x10, 0x1f, 0x22, 0xa2, 0x1b, 0x41, 0xdf, + 0xa3, 0xde, 0x62, 0x60, 0x8e, 0xa1, 0x60, 0xc4, 0xd5, 0xce, 0x81, 0x47, 0x84, 0x3b, 0x66, 0x60, 0x74, 0xa7, 0x74, + 0xcc, 0x48, 0xe0, 0xe1, 0xd5, 0x0c, 0x15, 0x98, 0x3d, 0xa7, 0x9c, 0xb2, 0xec, 0xba, 0x0f, 0x2c, 0x52, 0x14, 0x7b, + 0xe2, 0x49, 0x6e, 0x0f, 0x8c, 0x00, 0xe0, 0x81, 0xf6, 0x57, 0xe4, 0x3f, 0xbf, 0x7c, 0xa9, 0x5e, 0xfe, 0x01, 0xc2, + 0x64, 0xb0, 0x05, 0x60, 0x01, 0xaa, 0x78, 0x65, 0xb2, 0xeb, 0x56, 0x41, 0xf2, 0xbf, 0xa3, 0x45, 0x4e, 0x3c, 0x78, + 0xe2, 0xa1, 0x0d, 0xa9, 0x2a, 0xc0, 0x8a, 0xc0, 0x6f, 0xe4, 0x66, 0xbe, 0x72, 0x35, 0x5e, 0xf7, 0x3b, 0x42, 0x53, + 0xd4, 0xe6, 0x66, 0xb6, 0x78, 0xcd, 0xaa, 0x6f, 0xf4, 0x26, 0x80, 0x3a, 0x4e, 0x74, 0x80, 0x17, 0x88, 0x44, 0x23, + 0xa6, 0x3a, 0x6f, 0x87, 0xb8, 0xd0, 0x4d, 0xd3, 0xfc, 0x7c, 0xd6, 0x38, 0x2a, 0x00, 0x28, 0x01, 0xa2, 0x40, 0x94, + 0x6c, 0x1e, 0x8a, 0xed, 0xe3, 0x92, 0x9d, 0x90, 0xe3, 0x0d, 0x02, 0x4e, 0x15, 0x30, 0xed, 0x8f, 0x5b, 0x99, 0xaa, + 0x7a, 0x4e, 0xb9, 0xec, 0x91, 0xe2, 0x9f, 0xd4, 0xca, 0x46, 0xaf, 0x87, 0x19, 0x4b, 0xad, 0xea, 0xe6, 0x6c, 0x8d, + 0x53, 0x4b, 0x29, 0xee, 0x1e, 0x96, 0xd8, 0x94, 0x30, 0x3a, 0x9c, 0xb0, 0x4c, 0xdb, 0xe2, 0xa1, 0x7f, 0xc7, 0x11, + 0xdd, 0xe3, 0x9d, 0x36, 0x44, 0xd3, 0x93, 0x14, 0x9c, 0x4c, 0x9d, 0xdd, 0x3a, 0x7c, 0x41, 0xb1, 0x8f, 0x14, 0xd9, + 0x4e, 0x61, 0xd9, 0x3a, 0xe3, 0x0d, 0x76, 0xca, 0x6c, 0xac, 0x73, 0xaf, 0xad, 0x94, 0x87, 0x30, 0xf1, 0x30, 0x2f, + 0x8b, 0xed, 0x4a, 0xed, 0xbc, 0xc2, 0xf2, 0x7c, 0x30, 0xa3, 0x0b, 0x28, 0x64, 0x3b, 0x8c, 0xd4, 0xc3, 0x42, 0xb9, + 0xa3, 0x44, 0x51, 0x80, 0x07, 0x5a, 0x3d, 0x14, 0x33, 0x99, 0xbf, 0x2a, 0x6b, 0x2b, 0x19, 0x47, 0x72, 0x9e, 0xd4, + 0xb4, 0x6d, 0x72, 0xdd, 0x8a, 0x4b, 0x33, 0x55, 0xbc, 0xb4, 0xcd, 0xc8, 0x2b, 0x17, 0x2f, 0x74, 0xeb, 0x22, 0x17, + 0x94, 0x08, 0x27, 0x27, 0xc2, 0x5b, 0x17, 0xb4, 0xa9, 0x22, 0x16, 0x9d, 0xd4, 0xfc, 0xc7, 0x15, 0xa3, 0x9b, 0x86, + 0x1f, 0xad, 0x45, 0xd3, 0x87, 0x94, 0x5b, 0x31, 0x36, 0xaa, 0xe4, 0x66, 0x8d, 0xcc, 0x31, 0x05, 0x5b, 0xc4, 0x40, + 0xc0, 0xb8, 0xeb, 0x91, 0x18, 0x22, 0x8c, 0x31, 0x1e, 0xad, 0xd0, 0x3a, 0x98, 0x07, 0xb5, 0x6f, 0x11, 0xba, 0x11, + 0xa6, 0x14, 0x35, 0x5a, 0xe7, 0x55, 0xdf, 0xb7, 0x4c, 0x03, 0x61, 0xa3, 0x74, 0x23, 0xdf, 0x55, 0x1f, 0x02, 0x51, + 0x09, 0xb7, 0xba, 0xd5, 0x0c, 0x67, 0xab, 0x98, 0x70, 0x14, 0x64, 0x8d, 0xf4, 0x8b, 0x54, 0x44, 0x07, 0x6f, 0xe0, + 0x69, 0x32, 0xca, 0x48, 0xe5, 0xd3, 0xa7, 0x17, 0x8f, 0x45, 0x84, 0x04, 0xb7, 0xd1, 0xbb, 0xe1, 0xf6, 0x01, 0x0a, + 0xf6, 0xee, 0x2b, 0x32, 0xd2, 0xc5, 0xf8, 0xa6, 0xec, 0x0f, 0x1b, 0x09, 0x1d, 0xfc, 0xa2, 0xbf, 0x54, 0x5d, 0x2c, + 0x62, 0xf4, 0x77, 0xde, 0xad, 0xa0, 0x50, 0x6a, 0xb4, 0xe3, 0x5f, 0xda, 0xff, 0x6b, 0xb1, 0x78, 0xf7, 0xf9, 0xc1, + 0xa6, 0xa8, 0x93, 0xe8, 0xe4, 0x11, 0x56, 0xa0, 0x5b, 0x85, 0xa7, 0x92, 0x7a, 0x58, 0x45, 0x95, 0xa9, 0x63, 0x83, + 0xb4, 0x1f, 0x18, 0x31, 0x7a, 0x6d, 0xa1, 0x8d, 0x8c, 0xdc, 0x91, 0x02, 0x3c, 0x9c, 0x92, 0x42, 0x8e, 0x03, 0x02, + 0xc5, 0x0c, 0x43, 0x54, 0xf9, 0xb2, 0x85, 0x39, 0x2e, 0x77, 0xad, 0x00}; // Backwards compatibility alias #define INDEX_GZ INDEX_BR diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 365e5f64db..599422a46b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -2,7 +2,13 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/core/log.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "esphome/components/wifi/scan_list.h" #include "esphome/components/wifi/wifi_component.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #include "captive_index.h" namespace esphome::captive_portal { @@ -12,7 +18,7 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); @@ -24,23 +30,32 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) { stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif - for (auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) - continue; + // An SSID can contain a " or \ that would break the JSON, so escape it before writing it out. An SSID is at most + // 32 bytes (IEEE 802.11), so this is large enough that nothing is ever dropped. Reused for every scan result. + char escaped_ssid[32 * JSON_ESCAPE_MAX_EXPANSION + 1]; + { + // Invariant: only bounded in-memory work under the lock; the network send + // happens later in request->send() + wifi::ScanResultsLock lock(wifi::global_wifi_component); + const auto &results = wifi::global_wifi_component->get_scan_result(); + for (const auto &scan : results) { + bool with_auth = false; + if (!wifi::should_show_scan_entry(results, scan, with_auth)) + continue; - // Assumes no " in ssid, possible unicode isses? + json_escape_into_buffer(escaped_ssid, scan.get_ssid()); #ifdef USE_ESP8266 - stream->print(ESPHOME_F(",{\"ssid\":\"")); - stream->print(scan.get_ssid().c_str()); - stream->print(ESPHOME_F("\",\"rssi\":")); - stream->print(scan.get_rssi()); - stream->print(ESPHOME_F(",\"lock\":")); - stream->print(scan.get_with_auth()); - stream->print(ESPHOME_F("}")); + stream->print(ESPHOME_F(",{\"ssid\":\"")); + stream->print(escaped_ssid); + stream->print(ESPHOME_F("\",\"rssi\":")); + stream->print(scan.get_rssi()); + stream->print(ESPHOME_F(",\"lock\":")); + stream->print(with_auth); + stream->print(ESPHOME_F("}")); #else - stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", scan.get_ssid().c_str(), scan.get_rssi(), - scan.get_with_auth()); + stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), with_auth); #endif + } } stream->print(ESPHOME_F("]}")); request->send(stream); @@ -66,6 +81,20 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { void CaptivePortal::setup() { // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); +#ifdef USE_PROVISIONING + // The captive portal is a provisioning surface: once the provisioning window + // has closed, stop serving it. WiFi's own closed-callback shuts down the + // access point the portal runs on, and the gated fallback in WiFiComponent's + // loop() ensures neither is started again afterwards. + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + if (this->active_) { + ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal"); + this->end(); + } + }); + } +#endif } void CaptivePortal::start() { this->base_->init(); 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/__init__.py b/esphome/components/cc1101/__init__.py index 0feb384ac2..01e3ed0cd5 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -21,6 +21,7 @@ MULTI_CONF = True ns = cg.esphome_ns.namespace("cc1101") CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice) +CC1101Listener = ns.class_("CC1101Listener") # Config keys CONF_RX_ATTENUATION = "rx_attenuation" @@ -48,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk" CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" CONF_FREEZE = "freeze" CONF_HYST_LEVEL = "hyst_level" +CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate" +CONF_FOC_LIMIT = "foc_limit" +CONF_FOC_PRE_K = "foc_pre_k" +CONF_FOC_POST_K = "foc_post_k" +CONF_BS_LIMIT = "bs_limit" +CONF_BS_PRE_KI = "bs_pre_ki" +CONF_BS_PRE_KP = "bs_pre_kp" +CONF_BS_POST_KI = "bs_post_ki" +CONF_BS_POST_KP = "bs_post_kp" # Packet mode config keys CONF_PACKET_MODE = "packet_mode" @@ -161,6 +171,64 @@ HYST_LEVEL = { "High": HystLevel.HYST_LEVEL_HIGH, } +FocLimit = ns.enum("FocLimit", True) +FOC_LIMIT = { + "Disabled": FocLimit.FOC_LIMIT_DISABLED, + "BW/8": FocLimit.FOC_LIMIT_BW_8, + "BW/4": FocLimit.FOC_LIMIT_BW_4, + "BW/2": FocLimit.FOC_LIMIT_BW_2, +} + +FocPreK = ns.enum("FocPreK", True) +FOC_PRE_K = { + "K": FocPreK.FOC_PRE_K_K, + "2K": FocPreK.FOC_PRE_K_2K, + "3K": FocPreK.FOC_PRE_K_3K, + "4K": FocPreK.FOC_PRE_K_4K, +} + +FocPostK = ns.enum("FocPostK", True) +FOC_POST_K = { + "Same": FocPostK.FOC_POST_K_SAME, + "K/2": FocPostK.FOC_POST_K_K_2, +} + +BsLimit = ns.enum("BsLimit", True) +BS_LIMIT = { + "Disabled": BsLimit.BS_LIMIT_DISABLED, + "3.125%": BsLimit.BS_LIMIT_3P125_PERCENT, + "6.25%": BsLimit.BS_LIMIT_6P25_PERCENT, + "12.5%": BsLimit.BS_LIMIT_12P5_PERCENT, +} + +BsPreKi = ns.enum("BsPreKi", True) +BS_PRE_KI = { + "KI": BsPreKi.BS_PRE_KI_KI, + "2KI": BsPreKi.BS_PRE_KI_2KI, + "3KI": BsPreKi.BS_PRE_KI_3KI, + "4KI": BsPreKi.BS_PRE_KI_4KI, +} + +BsPreKp = ns.enum("BsPreKp", True) +BS_PRE_KP = { + "KP": BsPreKp.BS_PRE_KP_KP, + "2KP": BsPreKp.BS_PRE_KP_2KP, + "3KP": BsPreKp.BS_PRE_KP_3KP, + "4KP": BsPreKp.BS_PRE_KP_4KP, +} + +BsPostKi = ns.enum("BsPostKi", True) +BS_POST_KI = { + "Same": BsPostKi.BS_POST_KI_SAME, + "KI/2": BsPostKi.BS_POST_KI_KI_2, +} + +BsPostKp = ns.enum("BsPostKp", True) +BS_POST_KP = { + "Same": BsPostKp.BS_POST_KP_SAME, + "KP": BsPostKp.BS_POST_KP_KP, +} + # Optional settings to generate setter calls for CONFIG_MAP = { cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0), @@ -214,6 +282,15 @@ CONFIG_MAP = { cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False), cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False), cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False), + cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean, + cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False), + cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False), + cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False), + cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False), + cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False), + cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False), + cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False), + cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False), cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean, cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t, cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean, diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index ea0138e1dd..f7b90b91cf 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -672,6 +672,69 @@ void CC1101Component::set_hyst_level(HystLevel value) { } } +void CC1101Component::set_foc_bs_cs_gate(bool value) { + this->state_.FOC_BS_CS_GATE = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_limit(FocLimit value) { + this->state_.FOC_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_pre_k(FocPreK value) { + this->state_.FOC_PRE_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_foc_post_k(FocPostK value) { + this->state_.FOC_POST_K = static_cast(value); + if (this->initialized_) { + this->write_(Register::FOCCFG); + } +} + +void CC1101Component::set_bs_limit(BsLimit value) { + this->state_.BS_LIMIT = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_ki(BsPreKi value) { + this->state_.BS_PRE_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_pre_kp(BsPreKp value) { + this->state_.BS_PRE_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_ki(BsPostKi value) { + this->state_.BS_POST_KI = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + +void CC1101Component::set_bs_post_kp(BsPostKp value) { + this->state_.BS_POST_KP = static_cast(value); + if (this->initialized_) { + this->write_(Register::BSCFG); + } +} + void CC1101Component::set_packet_mode(bool value) { this->state_.PKT_FORMAT = static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 000a13d586..79bfc9cb33 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(); @@ -71,6 +71,17 @@ class CC1101Component : public Component, void set_wait_time(WaitTime value); void set_hyst_level(HystLevel value); + // Frequency offset compensation and bit synchronization settings + void set_foc_bs_cs_gate(bool value); + void set_foc_limit(FocLimit value); + void set_foc_pre_k(FocPreK value); + void set_foc_post_k(FocPostK value); + void set_bs_limit(BsLimit value); + void set_bs_pre_ki(BsPreKi value); + void set_bs_pre_kp(BsPreKp value); + void set_bs_post_ki(BsPostKi value); + void set_bs_post_kp(BsPostKp value); + // Packet mode settings void set_packet_mode(bool value); void set_packet_length(uint8_t value); @@ -119,27 +130,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 +174,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/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h index 59b29f7478..6748f4369a 100644 --- a/esphome/components/cc1101/cc1101defs.h +++ b/esphome/components/cc1101/cc1101defs.h @@ -231,6 +231,56 @@ enum class HystLevel : uint8_t { HYST_LEVEL_HIGH, }; +enum class FocLimit : uint8_t { + FOC_LIMIT_DISABLED, + FOC_LIMIT_BW_8, + FOC_LIMIT_BW_4, + FOC_LIMIT_BW_2, +}; + +enum class FocPreK : uint8_t { + FOC_PRE_K_K, + FOC_PRE_K_2K, + FOC_PRE_K_3K, + FOC_PRE_K_4K, +}; + +enum class FocPostK : uint8_t { + FOC_POST_K_SAME, + FOC_POST_K_K_2, +}; + +enum class BsLimit : uint8_t { + BS_LIMIT_DISABLED, + BS_LIMIT_3P125_PERCENT, + BS_LIMIT_6P25_PERCENT, + BS_LIMIT_12P5_PERCENT, +}; + +enum class BsPreKi : uint8_t { + BS_PRE_KI_KI, + BS_PRE_KI_2KI, + BS_PRE_KI_3KI, + BS_PRE_KI_4KI, +}; + +enum class BsPreKp : uint8_t { + BS_PRE_KP_KP, + BS_PRE_KP_2KP, + BS_PRE_KP_3KP, + BS_PRE_KP_4KP, +}; + +enum class BsPostKi : uint8_t { + BS_POST_KI_SAME, + BS_POST_KI_KI_2, +}; + +enum class BsPostKp : uint8_t { + BS_POST_KP_SAME, + BS_POST_KP_KP, +}; + enum class PacketFormat : uint8_t { PACKET_FORMAT_FIFO, PACKET_FORMAT_SYNC_SERIAL, 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/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index d9023a415f..d134d2cf21 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -18,6 +18,7 @@ from esphome.const import ( UNIT_PARTS_PER_BILLION, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType AUTO_LOAD = ["text_sensor"] CODEOWNERS = ["@habbie"] @@ -59,7 +60,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index af6866df78..5f7778e186 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -2,6 +2,7 @@ from esphome import pins import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_DELAY, CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["sensor", "voltage_sampler"] CODEOWNERS = ["@asoehlke"] @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/cd74hc4067/sensor.py b/esphome/components/cd74hc4067/sensor.py index dceaf6f371..670b050271 100644 --- a/esphome/components/cd74hc4067/sensor.py +++ b/esphome/components/cd74hc4067/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType from . import CD74HC4067Component, cd74hc4067_ns @@ -44,7 +45,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_CD74HC4067_ID]) var = cg.new_Pvariable(config[CONF_ID], parent) diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 6a7bace0a2..7f0c5bb95e 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -13,6 +13,8 @@ from esphome.const import ( CONF_OPEN_DRAIN, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jesterret", "@clydebarrow"] DEPENDENCIES = ["i2c"] @@ -35,7 +37,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS @@ -44,7 +46,7 @@ async def to_code(config): # This is used as a final validation step so that modes have been fully transformed. -def pin_mode_check(pin_config, _): +def pin_mode_check(pin_config: ConfigType, _: ConfigType) -> None: if pin_config[CONF_MODE][CONF_INPUT] and pin_config[CONF_NUMBER] >= 8: raise cv.Invalid("CH422G only supports input on pins 0-7") if pin_config[CONF_MODE][CONF_OPEN_DRAIN] and pin_config[CONF_NUMBER] < 8: @@ -63,7 +65,7 @@ CH422G_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH422G, CH422G_PIN_SCHEMA, pin_mode_check) -async def ch422g_pin_to_code(config): +async def ch422g_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_CH422G]) 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/__init__.py b/esphome/components/ch423/__init__.py index e3990ee631..9fbf3ea515 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -14,6 +14,8 @@ from esphome.const import ( CONF_OUTPUT, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@dwmw2"] DEPENDENCIES = ["i2c"] @@ -36,7 +38,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) # Can't use register_i2c_device because there is no CONF_ADDRESS @@ -45,7 +47,7 @@ async def to_code(config): # This is used as a final validation step so that modes have been fully transformed. -def pin_mode_check(pin_config, _): +def pin_mode_check(pin_config: ConfigType, _: ConfigType) -> None: if pin_config[CONF_MODE][CONF_INPUT] and pin_config[CONF_NUMBER] >= 8: raise cv.Invalid("CH423 only supports input on pins 0-7") if pin_config[CONF_MODE][CONF_OPEN_DRAIN] and pin_config[CONF_NUMBER] < 8: @@ -90,7 +92,7 @@ CH423_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_CH423, CH423_PIN_SCHEMA, pin_mode_check) -async def ch423_pin_to_code(config): +async def ch423_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_CH423]) 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/chsc6x/touchscreen.py b/esphome/components/chsc6x/touchscreen.py index 759e38609e..de974d2a79 100644 --- a/esphome/components/chsc6x/touchscreen.py +++ b/esphome/components/chsc6x/touchscreen.py @@ -3,6 +3,7 @@ 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 +from esphome.types import ConfigType chsc6x_ns = cg.esphome_ns.namespace("chsc6x") @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index fc1b0f368e..80dd913fba 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -48,13 +50,19 @@ from esphome.const import ( CONF_VISUAL, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import LambdaExpression, MockObjClass +from esphome.cpp_generator import ( + LambdaExpression, + MockObj, + MockObjClass, + TemplateArgsType, +) +from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True @@ -132,7 +140,7 @@ VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema( ) -def visual_temperature_step(value): +def visual_temperature_step(value: Any) -> ConfigType: # Allow defining target/current temperature steps separately if isinstance(value, dict): return VISUAL_TEMPERATURE_STEP_SCHEMA(value) @@ -273,8 +281,8 @@ def climate_schema( @setup_entity("climate") -async def setup_climate_core_(var, config): - visual = config[CONF_VISUAL] +async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: + visual = config.get(CONF_VISUAL, {}) if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) @@ -443,7 +451,7 @@ async def setup_climate_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_climate(var, config): +async def register_climate(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("climate", config) @@ -451,7 +459,7 @@ async def register_climate(var, config): await setup_climate_core_(var, config) -async def new_climate(config, *args): +async def new_climate(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_climate(var, config) return var @@ -485,7 +493,12 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema( CLIMATE_CONTROL_ACTION_SCHEMA, synchronous=True, ) -async def climate_control_to_code(config, action_id, template_arg, args): +async def climate_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) # All configured fields are folded into a single stateless lambda whose @@ -549,5 +562,5 @@ async def climate_control_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(climate_ns.using) 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/climate.cpp b/esphome/components/climate/climate.cpp index b41ca4a540..6ca9e394f7 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -511,29 +511,6 @@ ClimateTraits Climate::get_traits() { return traits; } -#ifdef USE_CLIMATE_VISUAL_OVERRIDES -void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { - this->visual_min_temperature_override_ = visual_min_temperature_override; -} - -void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) { - this->visual_max_temperature_override_ = visual_max_temperature_override; -} - -void Climate::set_visual_temperature_step_override(float target, float current) { - this->visual_target_temperature_step_override_ = target; - this->visual_current_temperature_step_override_ = current; -} - -void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) { - this->visual_min_humidity_override_ = visual_min_humidity_override; -} - -void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { - this->visual_max_humidity_override_ = visual_max_humidity_override; -} -#endif - ClimateCall Climate::make_call() { return ClimateCall(this); } ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { @@ -574,7 +551,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { void ClimateDeviceRestoreState::apply(Climate *climate) { auto traits = climate->get_traits(); - climate->mode = this->mode; + // A saved mode the device no longer offers cannot be selected again, so skip it and leave the + // entity on the mode it already has. The other saved fields are still restored. + if (traits.supports_mode(this->mode)) { + climate->mode = this->mode; + } else { + ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(), + LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode))); + } if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { climate->target_temperature_low = this->target_temperature_low; diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 04f653a2b0..a906897235 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -228,11 +228,22 @@ class Climate : public EntityBase { ClimateTraits get_traits(); #ifdef USE_CLIMATE_VISUAL_OVERRIDES - void set_visual_min_temperature_override(float visual_min_temperature_override); - void set_visual_max_temperature_override(float visual_max_temperature_override); - void set_visual_temperature_step_override(float target, float current); - void set_visual_min_humidity_override(float visual_min_humidity_override); - void set_visual_max_humidity_override(float visual_max_humidity_override); + void set_visual_min_temperature_override(float visual_min_temperature_override) { + this->visual_min_temperature_override_ = visual_min_temperature_override; + } + void set_visual_max_temperature_override(float visual_max_temperature_override) { + this->visual_max_temperature_override_ = visual_max_temperature_override; + } + void set_visual_temperature_step_override(float target, float current) { + this->visual_target_temperature_step_override_ = target; + this->visual_current_temperature_step_override_ = current; + } + void set_visual_min_humidity_override(float visual_min_humidity_override) { + this->visual_min_humidity_override_ = visual_min_humidity_override; + } + void set_visual_max_humidity_override(float visual_max_humidity_override) { + this->visual_max_humidity_override_ = visual_max_humidity_override; + } #endif /// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits). diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 599894c8a9..6c776e0228 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -205,6 +205,9 @@ class ClimateTraits { float get_visual_max_humidity() const { return this->visual_max_humidity_; } void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; } + TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; } + void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; } + protected: void set_mode_support_(climate::ClimateMode mode, bool supported) { if (supported) { @@ -274,6 +277,7 @@ class ClimateTraits { climate::ClimateFanModeMask supported_fan_modes_; climate::ClimateSwingModeMask supported_swing_modes_; climate::ClimatePresetMask supported_presets_; + TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS}; /** Custom mode storage - pointers to vectors owned by the Climate base class. * diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 5315be3db6..0667bd91a2 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -9,7 +9,8 @@ from esphome.const import ( CONF_SUPPORTS_COOL, CONF_SUPPORTS_HEAT, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, SafeExpType _LOGGER = logging.getLogger(__name__) @@ -57,7 +58,7 @@ def climate_ir_with_receiver_schema( ) -async def register_climate_ir(var, config): +async def register_climate_ir(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) await remote_base.register_transmittable(var, config) cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) @@ -72,7 +73,7 @@ async def register_climate_ir(var, config): cg.add(var.set_humidity_sensor(sens)) -async def new_climate_ir(config, *args): +async def new_climate_ir(config: ConfigType, *args: SafeExpType) -> MockObj: var = await climate.new_climate(config, *args) await register_climate_ir(var, config) return var diff --git a/esphome/components/climate_ir_lg/climate.py b/esphome/components/climate_ir_lg/climate.py index 9c832642ce..255fca9ad1 100644 --- a/esphome/components/climate_ir_lg/climate.py +++ b/esphome/components/climate_ir_lg/climate.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import climate_ir import esphome.config_validation as cv +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -12,9 +13,11 @@ CONF_HEADER_LOW = "header_low" CONF_BIT_HIGH = "bit_high" CONF_BIT_ONE_LOW = "bit_one_low" CONF_BIT_ZERO_LOW = "bit_zero_low" +CONF_ADVANCED_COMMANDS_SUPPORT = "advanced_commands_support" CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend( { + cv.Optional(CONF_ADVANCED_COMMANDS_SUPPORT, default=False): cv.boolean, cv.Optional( CONF_HEADER_HIGH, default="8000us" ): cv.positive_time_period_microseconds, @@ -34,9 +37,10 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) + cg.add(var.set_advanced_commands_support(config[CONF_ADVANCED_COMMANDS_SUPPORT])) cg.add(var.set_header_high(config[CONF_HEADER_HIGH])) cg.add(var.set_header_low(config[CONF_HEADER_LOW])) cg.add(var.set_bit_high(config[CONF_BIT_HIGH])) diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 588566dd9d..bb612eda7b 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -5,11 +5,85 @@ namespace esphome::climate_ir_lg { static const char *const TAG = "climate.climate_ir_lg"; -// Commands -const uint32_t COMMAND_MASK = 0xFF000; -const uint32_t COMMAND_OFF = 0xC0000; -const uint32_t COMMAND_SWING = 0x10000; +// All codes provided here are missing the checksum (last 4 bits) +// this checksum needs to be calculated before sending (look at `calc_checksum_()`) +const uint32_t LG_HEADER = 0x8800000; + +// Commands +const uint32_t COMMAND_HEADER_MASK = 0xFF000; +const uint32_t COMMAND_DATA_MASK = 0x00FF0; +const uint32_t CHECKSUM_MASK = 0xF; + +enum CommandBasic : uint32_t { + HEADER_BASIC = 0x10000, + BASIC_SWING_TOGGLE = 0x000, + + // JET MODE (only for cooling/drying/heating modes) + // For 30 minutes: max airflow (stronger than F5 aka FAN_MAX) + PO (min/min/max temperature respectively) + // After 30 minutes: F5 aka FAN_MAX + min/min/max temperature respectively + BASIC_JET = 0x080, +}; + +enum CommandSys : uint32_t { + HEADER_SYS = 0xC0000, + + COMMAND_OFF = 0x050, + + // Also known as 'auto-dry' + AUTO_CLEAN_ON = 0x0B0, + AUTO_CLEAN_OFF = 0x0C0, + + PURIFY_ON = 0x000, // From either OFF or Mode -> Purify + PURIFY_OFF = 0x080, // From Mode + Purify -> Mode + + QUIET_OUTDOOR_ON = 0xA60, + QUIET_OUTDOOR_OFF = 0xA70, + + // ENERGY CTRL (only in Cooling mode) + COOL_ENERG_CTRL_80 = 0x7D0, // 80% + COOL_ENERG_CTRL_60 = 0x7E0, // 60% + COOL_ENERG_CTRL_40 = 0x800, // 40% + COOL_ENERG_CTRL_OFF = 0x7F0, // OFF + + DISPLAY_KW = 0x460, + LIGHT_ON_OFF = 0x0A0, + + TEMP_UNIT_F = 0x170, + TEMP_UNIT_C = 0x160, +}; + +enum CommandAdvSwing : uint32_t { + HEADER_ADV_SWING = 0x13000, + + // Only 5 bits are relevant, I got 0x13952 once - not sure what is the 8th bit so ignoring that. + ADV_SWING_DATA_MASK = 0x1F0, + + // Commands for Advanced Vertical Control: Swing + 6 fixed positions + VERT_FIX_1 = 0x040, // Down + VERT_FIX_2 = 0x050, + VERT_FIX_3 = 0x060, + VERT_FIX_4 = 0x070, + VERT_FIX_5 = 0x080, + VERT_FIX_6 = 0x090, // Up + VERT_SWING_ON = 0x140, // Swing between 1 and 6 + VERT_SWING_OFF = 0x150, // Stops immediately + + // Commands for Advanced Horizontal Control: Swing (3 modes) + 5 fixed positions + HORI_FIX_1 = 0x0B0, // Left + HORI_FIX_2 = 0x0C0, + HORI_FIX_3 = 0x0D0, + HORI_FIX_4 = 0x0E0, + HORI_FIX_5 = 0x0F0, // Right + HORI_SWING_ON_LEFT = 0x100, // Swing between 1 and 3 + HORI_SWING_ON_RIGHT = 0x110, // Swing between 3 and 5 + HORI_SWING_ON_FULL = 0x160, // Swing between 1 and 5 + HORI_SWING_OFF = 0x170, // Stops immediately +}; + +// Following commands contain mode, fan speed and temperature + +// Modes const uint32_t COMMAND_ON_COOL = 0x00000; const uint32_t COMMAND_ON_DRY = 0x01000; const uint32_t COMMAND_ON_FAN_ONLY = 0x02000; @@ -23,11 +97,13 @@ const uint32_t COMMAND_AI = 0x0B000; const uint32_t COMMAND_HEAT = 0x0C000; // Fan speed -const uint32_t FAN_MASK = 0xF0; +const uint32_t FAN_SPEED_MASK = 0xF0; const uint32_t FAN_AUTO = 0x50; -const uint32_t FAN_MIN = 0x00; -const uint32_t FAN_MED = 0x20; -const uint32_t FAN_MAX = 0x40; +const uint32_t FAN_MIN = 0x00; // AKA F1 +const uint32_t FAN_F2 = 0x90; +const uint32_t FAN_MED = 0x20; // AKA F3 +const uint32_t FAN_F4 = 0xA0; +const uint32_t FAN_MAX = 0x40; // AKA F5 // Temperature const uint8_t TEMP_RANGE = TEMP_MAX - TEMP_MIN + 1; @@ -37,16 +113,37 @@ const uint32_t TEMP_SHIFT = 8; const uint16_t BITS = 28; void LgIrClimate::transmit_state() { - uint32_t remote_state = 0x8800000; + uint32_t remote_state = LG_HEADER; - // ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", modeBefore_); + // ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", this->modeBefore_); // Set command if (this->send_swing_cmd_) { this->send_swing_cmd_ = false; - remote_state |= COMMAND_SWING; - } else { - bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); + if (this->advanced_commands_support_) { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + ESP_LOGD(TAG, "setting swing vertical"); + remote_state |= CommandAdvSwing::HEADER_ADV_SWING; + remote_state |= CommandAdvSwing::VERT_SWING_ON; + break; + case climate::CLIMATE_SWING_OFF: + ESP_LOGD(TAG, "setting swing off"); + remote_state |= CommandAdvSwing::HEADER_ADV_SWING; + remote_state |= CommandAdvSwing::VERT_SWING_OFF; + break; + default: + return; + } + this->transmit_(remote_state); + this->publish_state(); + return; + } else { // just toggle swing when advanced_commands_support is not set + remote_state |= HEADER_BASIC; + remote_state |= BASIC_SWING_TOGGLE; + } + } else { // Mode commands + const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); switch (this->mode) { case climate::CLIMATE_MODE_COOL: remote_state |= climate_is_off ? COMMAND_ON_COOL : COMMAND_COOL; @@ -65,8 +162,8 @@ void LgIrClimate::transmit_state() { break; case climate::CLIMATE_MODE_OFF: default: - remote_state |= COMMAND_OFF; - break; + remote_state |= CommandSys::HEADER_SYS; + remote_state |= CommandSys::COMMAND_OFF; } } @@ -75,9 +172,8 @@ void LgIrClimate::transmit_state() { ESP_LOGD(TAG, "climate_lg_ir mode code: 0x%02X", this->mode); // Set fan speed - if (this->mode == climate::CLIMATE_MODE_OFF) { - remote_state |= FAN_AUTO; - } else { + if (this->mode != + climate::CLIMATE_MODE_OFF) { // https://github.com/esphome/esphome/pull/10875#issuecomment-5042765948 switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_HIGH: remote_state |= FAN_MAX; @@ -95,10 +191,20 @@ void LgIrClimate::transmit_state() { } } - // Set temperature - if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) { - auto temp = (uint8_t) roundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); - remote_state |= ((temp - 15) << TEMP_SHIFT); + uint8_t temp; + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + if (!this->advanced_commands_support_) { // Keep previous behavior + break; + } + [[fallthrough]]; + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_HEAT: + temp = static_cast(roundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX))); + remote_state |= (temp - 15) << TEMP_SHIFT; + break; + default: + break; } this->transmit_(remote_state); @@ -124,62 +230,134 @@ bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) { } } - ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state); - if ((remote_state & 0xFF00000) != 0x8800000) + ESP_LOGD(TAG, "Received 0x%02" PRIX32, remote_state); + if ((remote_state & 0xFF00000) != LG_HEADER) return false; - // Get command - if ((remote_state & COMMAND_MASK) == COMMAND_OFF) { - this->mode = climate::CLIMATE_MODE_OFF; - } else if ((remote_state & COMMAND_MASK) == COMMAND_SWING) { - this->swing_mode = - this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; - } else { - switch (remote_state & COMMAND_MASK) { - case COMMAND_DRY: - case COMMAND_ON_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - case COMMAND_FAN_ONLY: - case COMMAND_ON_FAN_ONLY: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - case COMMAND_AI: - case COMMAND_ON_AI: - this->mode = climate::CLIMATE_MODE_HEAT_COOL; - break; - case COMMAND_HEAT: - case COMMAND_ON_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - case COMMAND_COOL: - case COMMAND_ON_COOL: - default: - this->mode = climate::CLIMATE_MODE_COOL; - break; - } - - // Get fan speed - if (this->mode == climate::CLIMATE_MODE_HEAT_COOL) { - this->fan_mode = climate::CLIMATE_FAN_AUTO; - } else if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_DRY || - this->mode == climate::CLIMATE_MODE_FAN_ONLY || this->mode == climate::CLIMATE_MODE_HEAT) { - if ((remote_state & FAN_MASK) == FAN_AUTO) { - this->fan_mode = climate::CLIMATE_FAN_AUTO; - } else if ((remote_state & FAN_MASK) == FAN_MIN) { - this->fan_mode = climate::CLIMATE_FAN_LOW; - } else if ((remote_state & FAN_MASK) == FAN_MED) { - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - } else if ((remote_state & FAN_MASK) == FAN_MAX) { - this->fan_mode = climate::CLIMATE_FAN_HIGH; + // Decode commands + switch (remote_state & COMMAND_HEADER_MASK) { + case CommandSys::HEADER_SYS: + ESP_LOGD(TAG, "Got system command! With data: 0x%02" PRIX32, remote_state & COMMAND_DATA_MASK); + if ((remote_state & COMMAND_DATA_MASK) == CommandSys::COMMAND_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + } else { + return false; + } + break; + case CommandAdvSwing::HEADER_ADV_SWING: + ESP_LOGD(TAG, "Got advanced swing command! With data: 0x%02" PRIX32, + remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK); + switch (remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK) { + case CommandAdvSwing::VERT_SWING_ON: + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + break; + case CommandAdvSwing::VERT_SWING_OFF: + case CommandAdvSwing::VERT_FIX_1: + case CommandAdvSwing::VERT_FIX_2: + case CommandAdvSwing::VERT_FIX_3: + case CommandAdvSwing::VERT_FIX_4: + case CommandAdvSwing::VERT_FIX_5: + case CommandAdvSwing::VERT_FIX_6: + this->swing_mode = climate::CLIMATE_SWING_OFF; + break; + default: + return false; // Ignore all other (horizontal) swing commands } - } - // Get temperature - if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) { - this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15; - } + this->publish_state(); + return true; + + case HEADER_BASIC: + if ((remote_state & COMMAND_DATA_MASK) == BASIC_JET) { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_HEAT: + case climate::CLIMATE_MODE_DRY: + this->target_temperature = + this->mode == climate::CLIMATE_MODE_HEAT ? this->maximum_temperature_ : this->minimum_temperature_; + this->fan_mode = climate::CLIMATE_FAN_HIGH; + // When enabling PO(WER) also known as JET mode, swing is set to VERT_3, but after 30 mins it will switch + // back to what it was before, so let's just not change it here it at all + this->publish_state(); + return true; + default: + ESP_LOGD(TAG, "Got jet command, but current mode does not support it! Ignoring."); + return false; + } + } + + // Keep previous behavior in case of other BASIC command + if (this->swing_mode == climate::CLIMATE_SWING_OFF) { // Just flip between vertical and off + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + this->publish_state(); + return true; + // Following commands also contain fan speed and temperature, so no 'return' in these cases + case COMMAND_DRY: + case COMMAND_ON_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case COMMAND_FAN_ONLY: + case COMMAND_ON_FAN_ONLY: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case COMMAND_AI: + case COMMAND_ON_AI: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case COMMAND_HEAT: + case COMMAND_ON_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case COMMAND_COOL: + case COMMAND_ON_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + default: + ESP_LOGD(TAG, "Got unknown command! Ignoring!"); + return false; } + + // Decode fan speed + switch (remote_state & FAN_SPEED_MASK) { + case FAN_AUTO: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case FAN_MIN: + case FAN_F2: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case FAN_MED: + case FAN_F4: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case FAN_MAX: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + default: + ESP_LOGD(TAG, "Got unknown fan speed! Ignoring!"); + return false; + } + + // Keep previous behavior + if (this->mode == climate::CLIMATE_MODE_HEAT_COOL && !(this->advanced_commands_support_)) { + this->fan_mode = climate::CLIMATE_FAN_AUTO; + } + + // Decode temperature for modes that support it + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_HEAT: + this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15; + break; + default: + break; + } + + this->mode_before_ = this->mode; this->publish_state(); return true; @@ -207,14 +385,14 @@ void LgIrClimate::transmit_(uint32_t value) { data->mark(this->bit_high_); transmit.perform(); } + void LgIrClimate::calc_checksum_(uint32_t &value) { - uint32_t mask = 0xF; uint32_t sum = 0; for (uint8_t i = 1; i < 8; i++) { - sum += (value & (mask << (i * 4))) >> (i * 4); + sum += (value & (CHECKSUM_MASK << (i * 4))) >> (i * 4); } - value |= (sum & mask); + value |= (sum & CHECKSUM_MASK); } } // namespace esphome::climate_ir_lg diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.h b/esphome/components/climate_ir_lg/climate_ir_lg.h index a09da65ac6..c9c0c0c005 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, @@ -21,12 +21,13 @@ class LgIrClimate : public climate_ir::ClimateIR { /// Override control to change settings of the climate device. void control(const climate::ClimateCall &call) override { this->send_swing_cmd_ = call.get_swing_mode().has_value(); - // swing resets after unit powered off + // swing resets after unit powered off, except when advanced_commands_support_ is set auto mode = call.get_mode(); - if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF) + if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF && !(this->advanced_commands_support_)) this->swing_mode = climate::CLIMATE_SWING_OFF; climate_ir::ClimateIR::control(call); } + void set_advanced_commands_support(bool value) { this->advanced_commands_support_ = value; } void set_header_high(uint32_t header_high) { this->header_high_ = header_high; } void set_header_low(uint32_t header_low) { this->header_low_ = header_low; } void set_bit_high(uint32_t bit_high) { this->bit_high_ = bit_high; } @@ -44,6 +45,7 @@ class LgIrClimate : public climate_ir::ClimateIR { void calc_checksum_(uint32_t &value); void transmit_(uint32_t value); + bool advanced_commands_support_{false}; uint32_t header_high_; uint32_t header_low_; uint32_t bit_high_; 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/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 3c82fac977..936c5fc673 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -13,6 +13,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] @@ -44,7 +47,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: """Code generation entry point.""" var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -67,7 +70,12 @@ CALIBRATION_ACTION_SCHEMA = maybe_simple_id( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: +async def cm1106_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: """Service code generation entry point.""" paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index c39c5924af..70240eff07 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -1,5 +1,8 @@ +from typing import Any + from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_BLUE, CONF_GREEN, CONF_ID, CONF_RED, CONF_WHITE +from esphome.types import ConfigType ColorStruct = cg.esphome_ns.struct("Color") @@ -14,7 +17,7 @@ CONF_WHITE_INT = "white_int" CONF_HEX = "hex" -def hex_color(value): +def hex_color(value: Any) -> tuple[int, int, int]: if isinstance(value, int): value = str(value) if not isinstance(value, str): @@ -39,7 +42,7 @@ components = { } -def validate_color(config): +def validate_color(config: ConfigType) -> ConfigType: has_components = set(config) & components has_hex = CONF_HEX in config if has_hex and has_components: @@ -68,7 +71,7 @@ CONFIG_SCHEMA = cv.All( ) -def from_rgbw(config): +def from_rgbw(config: ConfigType) -> tuple[int, int, int, int]: r = 0 if CONF_RED in config: r = int(config[CONF_RED] * 255) @@ -96,7 +99,7 @@ def from_rgbw(config): return (r, g, b, w) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_HEX in config: r, g, b = config[CONF_HEX] w = 0 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/color_temperature/light.py b/esphome/components/color_temperature/light.py index 045ab265cd..7686ede155 100644 --- a/esphome/components/color_temperature/light.py +++ b/esphome/components/color_temperature/light.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_OUTPUT_ID, CONF_WARM_WHITE_COLOR_TEMPERATURE, ) +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -28,7 +29,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) 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/combination/sensor.py b/esphome/components/combination/sensor.py index 327cedee1e..ccc5a03964 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -74,7 +75,7 @@ KALMAN_SOURCE_SCHEMA = cv.Schema( ) -def _migrate_coeffecient(config): +def _migrate_coeffecient(config: ConfigType) -> ConfigType: """Migrate deprecated 'coeffecient' spelling to 'coefficient'.""" if CONF_COEFFECIENT in config: if CONF_COEFFICIENT in config: @@ -172,7 +173,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6f418b48ea..10710c8d29 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -5,29 +5,50 @@ CODEOWNERS = ["@esphome/core"] BYTE_ORDER_LITTLE = "little_endian" 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_CHANNEL_COLORS = "channel_colors" 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_IS_WRGB = "is_wrgb" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" +CONF_LOOP = "loop" +CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" +CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_REQUEST_HEADERS = "request_headers" CONF_ROWS = "rows" +CONF_SCAN_PARAMETERS = "scan_parameters" +CONF_SHA256 = "sha256" +CONF_SLOT = "slot" +CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" +CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" CONF_VOLUME_MAX = "volume_max" CONF_VOLUME_MIN = "volume_min" +CONF_WINDOW = "window" ICON_CURRENT_DC = "mdi:current-dc" ICON_SOLAR_PANEL = "mdi:solar-panel" diff --git a/esphome/components/coolix/climate.py b/esphome/components/coolix/climate.py index 1ebcff3c1b..3eb8dbe2f4 100644 --- a/esphome/components/coolix/climate.py +++ b/esphome/components/coolix/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] CODEOWNERS = ["@glmnet"] @@ -10,5 +11,5 @@ CoolixClimate = coolix_ns.class_("CoolixClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(CoolixClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index 840200409f..cc8492f21e 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/button/__init__.py index 8028d6a217..768131bbe5 100644 --- a/esphome/components/copy/button/__init__.py +++ b/esphome/components/copy/button/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -32,7 +33,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await button.register_button(var, config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/cover/__init__.py index ff5bef5668..d23602fa74 100644 --- a/esphome/components/copy/cover/__init__.py +++ b/esphome/components/copy/cover/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/fan/__init__.py index a208e5f80a..ffa414c5f2 100644 --- a/esphome/components/copy/fan/__init__.py +++ b/esphome/components/copy/fan/__init__.py @@ -3,6 +3,7 @@ from esphome.components import fan import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/lock/__init__.py index 46bc08273e..8d9c4b6eca 100644 --- a/esphome/components/copy/lock/__init__.py +++ b/esphome/components/copy/lock/__init__.py @@ -3,6 +3,7 @@ from esphome.components import lock import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await lock.new_lock(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/number/__init__.py index 3e2bbf2aae..9659a605f9 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/select/__init__.py index d7ddc52c44..97776b1edd 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -3,6 +3,7 @@ from esphome.components import select import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_ID, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await select.register_select(var, config, options=[]) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/sensor/__init__.py index 57ca06aca7..5468798047 100644 --- a/esphome/components/copy/sensor/__init__.py +++ b/esphome/components/copy/sensor/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -37,7 +38,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/switch/__init__.py index ee27e38c5f..0e714540f9 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_SOURCE_ID, ) from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -31,7 +32,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/text/__init__.py index f1ca404b7b..59fdce6c96 100644 --- a/esphome/components/copy/text/__init__.py +++ b/esphome/components/copy/text/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_MODE, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -26,7 +27,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text.new_text(config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 7b38ff1a64..146beae5ea 100644 --- a/esphome/components/copy/text_sensor/__init__.py +++ b/esphome/components/copy/text_sensor/__init__.py @@ -3,6 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ENTITY_CATEGORY, CONF_ICON, CONF_SOURCE_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType from .. import copy_ns @@ -25,7 +26,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) 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..011b2c2f04 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -46,7 +46,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -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 ), @@ -160,7 +162,7 @@ _COVER_SCHEMA = ( _COVER_SCHEMA.add_extra(entity_duplicate_validator("cover")) -def _validate_mqtt_state_topics(config): +def _validate_mqtt_state_topics(config: ConfigType) -> ConfigType: if config.get(CONF_MQTT_JSON_STATE_PAYLOAD): if CONF_POSITION_STATE_TOPIC in config: raise cv.Invalid( @@ -199,7 +201,7 @@ def cover_schema( @setup_entity("cover") -async def setup_cover_core_(var, config): +async def setup_cover_core_(var: MockObj, config: ConfigType) -> None: setup_device_class(config) if CONF_ON_OPEN in config: @@ -233,7 +235,7 @@ async def setup_cover_core_(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_cover(var, config): +async def register_cover(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("cover", config) @@ -241,7 +243,7 @@ async def register_cover(var, config): await setup_cover_core_(var, config) -async def new_cover(config, *args): +async def new_cover(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_cover(var, config) return var @@ -257,7 +259,12 @@ COVER_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_open_to_code(config, action_id, template_arg, args): +async def cover_open_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -265,7 +272,12 @@ async def cover_open_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_close_to_code(config, action_id, template_arg, args): +async def cover_close_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -273,7 +285,12 @@ async def cover_close_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_stop_to_code(config, action_id, template_arg, args): +async def cover_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -281,7 +298,12 @@ async def cover_stop_to_code(config, action_id, template_arg, args): @automation.register_action( "cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True ) -async def cover_toggle_to_code(config, action_id, template_arg, args): +async def cover_toggle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @@ -419,5 +441,5 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(cover_ns.using) 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/cover/cover.cpp b/esphome/components/cover/cover.cpp index e98a555fe5..dc2db3bf32 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -135,10 +135,6 @@ CoverCall &CoverCall::set_stop(bool stop) { this->stop_ = stop; return *this; } -bool CoverCall::get_stop() const { return this->stop_; } - -CoverCall Cover::make_call() { return {this}; } - void Cover::publish_state(bool save) { this->position = clamp(this->position, 0.0f, 1.0f); this->tilt = clamp(this->tilt, 0.0f, 1.0f); @@ -184,9 +180,6 @@ optional Cover::restore_state_() { return recovered; } -bool Cover::is_fully_open() const { return this->position == COVER_OPEN; } -bool Cover::is_fully_closed() const { return this->position == COVER_CLOSED; } - CoverCall CoverRestoreState::to_call(Cover *cover) { auto call = cover->make_call(); auto traits = cover->get_traits(); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index 9a75e68487..8bf45cfb57 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -50,7 +50,7 @@ class CoverCall { void perform(); const optional &get_position() const; - bool get_stop() const; + bool get_stop() const { return this->stop_; } const optional &get_tilt() const; const optional &get_toggle() const; @@ -123,7 +123,7 @@ class Cover : public EntityBase { float tilt{COVER_OPEN}; /// Construct a new cover call used to control the cover. - CoverCall make_call(); + CoverCall make_call() { return {this}; } template void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward(f)); } @@ -139,9 +139,9 @@ class Cover : public EntityBase { virtual CoverTraits get_traits() = 0; /// Helper method to check if the cover is fully open. Equivalent to comparing .position against 1.0 - bool is_fully_open() const; + bool is_fully_open() const { return this->position == COVER_OPEN; } /// Helper method to check if the cover is fully closed. Equivalent to comparing .position against 0.0 - bool is_fully_closed() const; + bool is_fully_closed() const { return this->position == COVER_CLOSED; } protected: friend CoverCall; 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/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 0c6ae0d821..5f14457101 100644 --- a/esphome/components/cs5460a/sensor.py +++ b/esphome/components/cs5460a/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@balrog-kun"] DEPENDENCIES = ["spi"] @@ -40,7 +43,7 @@ CONF_VOLTAGE_HPF = "voltage_hpf" CONF_PULSE_ENERGY = "pulse_energy" -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: current_gain = abs(config[CONF_CURRENT_GAIN]) * ( 1.0 if config[CONF_PGA_GAIN] == "10X" else 5.0 ) @@ -105,7 +108,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) @@ -138,6 +141,11 @@ async def to_code(config): ), synchronous=True, ) -async def restart_action_to_code(config, action_id, template_arg, args): +async def restart_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) 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/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 7e8caf1ae1..b53ed26ca3 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] DEPENDENCIES = ["uart"] @@ -71,7 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/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/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 94ed66d7cc..a1a68e18e8 100644 --- a/esphome/components/cse7766/sensor.py +++ b/esphome/components/cse7766/sensor.py @@ -26,6 +26,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -87,7 +88,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/cst226/binary_sensor/__init__.py b/esphome/components/cst226/binary_sensor/__init__.py index 324d794772..7fd81f6c18 100644 --- a/esphome/components/cst226/binary_sensor/__init__.py +++ b/esphome/components/cst226/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import cst226_ns from ..touchscreen import CST226ButtonListener, CST226Touchscreen @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_CST226_ID]) 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/__init__.py b/esphome/components/cst226/touchscreen/__init__.py index 62c2e3b20a..459cba61cd 100644 --- a/esphome/components/cst226/touchscreen/__init__.py +++ b/esphome/components/cst226/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 esphome.types import ConfigType from .. import cst226_ns @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) 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..33e68a112b --- /dev/null +++ b/esphome/components/cst328/binary_sensor/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +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: ConfigType) -> None: + 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..9bc7744b7c --- /dev/null +++ b/esphome/components/cst328/touchscreen/__init__.py @@ -0,0 +1,39 @@ +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 esphome.types import ConfigType + +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: ConfigType) -> None: + 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/__init__.py b/esphome/components/cst816/touchscreen/__init__.py index 288ca17593..029a544a91 100644 --- a/esphome/components/cst816/touchscreen/__init__.py +++ b/esphome/components/cst816/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 esphome.types import ConfigType from .. import cst816_ns @@ -25,7 +26,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x15)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) 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..393685e67b --- /dev/null +++ b/esphome/components/cst9220/touchscreen/__init__.py @@ -0,0 +1,37 @@ +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 esphome.types import ConfigType + +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: ConfigType) -> None: + 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/ct_clamp/sensor.py b/esphome/components/ct_clamp/sensor.py index 6ad7990e80..8ef211cb24 100644 --- a/esphome/components/ct_clamp/sensor.py +++ b/esphome/components/ct_clamp/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_AMPERE, ) +from esphome.types import ConfigType AUTO_LOAD = ["voltage_sampler"] CODEOWNERS = ["@jesserockz"] @@ -36,7 +37,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/current_based/cover.py b/esphome/components/current_based/cover.py index 99952adb12..a552956082 100644 --- a/esphome/components/current_based/cover.py +++ b/esphome/components/current_based/cover.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_OPEN_DURATION, CONF_STOP_ACTION, ) +from esphome.types import ConfigType CONF_OPEN_SENSOR = "open_sensor" CONF_OPEN_MOVING_CURRENT_THRESHOLD = "open_moving_current_threshold" @@ -67,7 +68,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) 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/cwww/light.py b/esphome/components/cwww/light.py index 50d84a582d..90fe6d0bad 100644 --- a/esphome/components/cwww/light.py +++ b/esphome/components/cwww/light.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WARM_WHITE_COLOR_TEMPERATURE, ) +from esphome.types import ConfigType cwww_ns = cg.esphome_ns.namespace("cwww") CWWWLightOutput = cwww_ns.class_("CWWWLightOutput", light.LightOutput) @@ -31,7 +32,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) diff --git a/esphome/components/dac7678/__init__.py b/esphome/components/dac7678/__init__.py index 842c84832e..668cc87cec 100644 --- a/esphome/components/dac7678/__init__.py +++ b/esphome/components/dac7678/__init__.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["output"] CODEOWNERS = ["@NickB1"] @@ -24,7 +26,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add(var.set_internal_reference(config[CONF_INTERNAL_REFERENCE])) 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 Parented MockObj: paren = await cg.get_variable(config[CONF_DAC7678_ID]) var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/daikin/climate.py b/esphome/components/daikin/climate.py index 7f0226143b..c9f9cb189f 100644 --- a/esphome/components/daikin/climate.py +++ b/esphome/components/daikin/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -9,5 +10,5 @@ DaikinClimate = daikin_ns.class_("DaikinClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index 9c05993f37..b7fa992307 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -39,7 +39,7 @@ const uint32_t DAIKIN_MESSAGE_SPACE = 32300; // State Frame size const uint8_t DAIKIN_STATE_FRAME_SIZE = 19; -class DaikinClimate : public climate_ir::ClimateIR { +class DaikinClimate final : public climate_ir::ClimateIR { public: DaikinClimate() : climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true, diff --git a/esphome/components/daikin_arc/climate.py b/esphome/components/daikin_arc/climate.py index dbaf12d959..210ec6987e 100644 --- a/esphome/components/daikin_arc/climate.py +++ b/esphome/components/daikin_arc/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -9,5 +10,5 @@ DaikinArcClimate = daikin_arc_ns.class_("DaikinArcClimate", climate_ir.ClimateIR CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinArcClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index a455e2fd7f..e31f72dfb9 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -216,7 +216,7 @@ uint8_t DaikinArcClimate::temperature_() { return 0xc0; default: float new_temp = clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX); - uint8_t temperature = (uint8_t) floor(new_temp); + uint8_t temperature = (uint8_t) std::floor(new_temp); return temperature << 1 | (new_temp - temperature > 0 ? 0x01 : 0); } } diff --git a/esphome/components/daikin_arc/daikin_arc.h b/esphome/components/daikin_arc/daikin_arc.h index 2337351e28..9fac9c7ea4 100644 --- a/esphome/components/daikin_arc/daikin_arc.h +++ b/esphome/components/daikin_arc/daikin_arc.h @@ -45,7 +45,7 @@ const uint8_t DAIKIN_DBG_TOLERANCE = 25; // State Frame size const uint8_t DAIKIN_STATE_FRAME_SIZE = 19; -class DaikinArcClimate : public climate_ir::ClimateIR { +class DaikinArcClimate final : public climate_ir::ClimateIR { public: DaikinArcClimate() : climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true, diff --git a/esphome/components/daikin_brc/climate.py b/esphome/components/daikin_brc/climate.py index 5b7a4631a9..c5c1d3739e 100644 --- a/esphome/components/daikin_brc/climate.py +++ b/esphome/components/daikin_brc/climate.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate_ir import esphome.config_validation as cv from esphome.const import CONF_USE_FAHRENHEIT +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -16,6 +17,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5fe3d30a85..1b085013f1 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -151,7 +151,7 @@ uint8_t DaikinBrcClimate::temperature_() { // Temperature in remote is in F if (this->fahrenheit_) { 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/dallas_temp/sensor.py b/esphome/components/dallas_temp/sensor.py index 3d35881722..c441504947 100644 --- a/esphome/components/dallas_temp/sensor.py +++ b/esphome/components/dallas_temp/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType dallas_temp_ns = cg.esphome_ns.namespace("dallas_temp") @@ -35,7 +36,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await one_wire.register_one_wire_device(var, config) diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index 87f00ce507..ba0be4d3a5 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] MULTI_CONF = True @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/daly_bms/binary_sensor.py b/esphome/components/daly_bms/binary_sensor.py index 95a2ae3b44..2b6ceffff1 100644 --- a/esphome/components/daly_bms/binary_sensor.py +++ b/esphome/components/daly_bms/binary_sensor.py @@ -1,6 +1,8 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -27,13 +29,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): var = await binary_sensor.new_binary_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_binary_sensor")(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) 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/daly_bms/sensor.py b/esphome/components/daly_bms/sensor.py index aa92cfa86a..3e91fb280a 100644 --- a/esphome/components/daly_bms/sensor.py +++ b/esphome/components/daly_bms/sensor.py @@ -23,6 +23,8 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -222,13 +224,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await sensor.new_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/daly_bms/text_sensor.py b/esphome/components/daly_bms/text_sensor.py index 9f4e2df85a..1a91081bbf 100644 --- a/esphome/components/daly_bms/text_sensor.py +++ b/esphome/components/daly_bms/text_sensor.py @@ -2,6 +2,8 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_STATUS +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMS_DALY_ID, DalyBmsComponent @@ -23,13 +25,13 @@ CONFIG_SCHEMA = cv.All( ) -async def setup_conf(config, key, hub): +async def setup_conf(config: ConfigType, key: str, hub: MockObj) -> None: if sensor_config := config.get(key): sens = await text_sensor.new_text_sensor(sensor_config) cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_BMS_DALY_ID]) for key in TYPES: await setup_conf(config, key, hub) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 30b3394165..906e9762bf 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -2,8 +2,8 @@ import base64 from pathlib import Path import re import secrets +from typing import Any -import requests from ruamel.yaml import YAML from esphome import git @@ -12,6 +12,8 @@ from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI import esphome.final_validate as fv +from esphome.net_retry import fetch_with_retry, http_request +from esphome.types import ConfigType from esphome.yaml_util import dump dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") @@ -22,14 +24,14 @@ DEPENDENCIES = ["api"] CODEOWNERS = ["@esphome/core"] -def validate_import_url(value): +def validate_import_url(value: Any) -> str: value = cv.string_strict(value) value = cv.Length(max=255)(value) validate_source_shorthand(value) return value -def validate_full_url(config): +def validate_full_url(config: ConfigType) -> ConfigType: if not config[CONF_IMPORT_FULL_CONFIG]: return config source = validate_source_shorthand(config[CONF_PACKAGE_IMPORT_URL]) @@ -54,7 +56,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get()[CONF_ESPHOME] if CONF_PROJECT not in full_config: raise cv.Invalid( @@ -72,12 +74,12 @@ wifi: """ -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_DASHBOARD_IMPORT") 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 +94,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 @@ -109,13 +110,20 @@ def import_config( if git_file.query and "full_config" in git_file.query: url = git_file.raw_url - try: - req = requests.get(url, timeout=30) + + # Deferred so config-time imports of this component stay light; + # http_request does the lazy import for the request itself. + import requests + + def _fetch() -> str: + req = http_request("GET", url, timeout=30) req.raise_for_status() + return req.text + + try: + contents = fetch_with_retry(url, _fetch, what="Import") except requests.exceptions.RequestException as e: raise ValueError(f"Error while fetching {url}: {e}") from e - - contents = req.text yaml = YAML() loaded_yaml = yaml.load(contents) if ( 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/__init__.py b/esphome/components/datetime/__init__.py index 87997daa3d..f8b6446006 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -21,13 +21,14 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_YEAR, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] @@ -65,7 +66,7 @@ DATETIME_MODES = [ ] -def _validate_time_present(config): +def _validate_time_present(config: ConfigType) -> ConfigType: config = config.copy() if CONF_ON_TIME in config and CONF_TIME_ID not in config: time_id = cv.use_id(time.RealTimeClock)(None) @@ -139,7 +140,7 @@ def datetime_schema(class_: MockObjClass) -> cv.Schema: @setup_entity("datetime") -async def setup_datetime_core_(var, config): +async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) await mqtt.register_mqtt_component(mqtt_, config) @@ -160,7 +161,7 @@ async def setup_datetime_core_(var, config): await cg.register_parented(trigger, var) -async def register_datetime(var, config): +async def register_datetime(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) entity_type = config[CONF_TYPE].lower() @@ -169,14 +170,14 @@ async def register_datetime(var, config): await setup_datetime_core_(var, config) -async def new_datetime(config, *args): +async def new_datetime(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_datetime(var, config) return var @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(datetime_ns.using) @@ -193,7 +194,12 @@ async def to_code(config): ), synchronous=True, ) -async def datetime_date_set_to_code(config, action_id, template_arg, args): +async def datetime_date_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -226,7 +232,12 @@ async def datetime_date_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_time_set_to_code(config, action_id, template_arg, args): +async def datetime_time_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) @@ -259,7 +270,12 @@ async def datetime_time_set_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def datetime_datetime_set_to_code(config, action_id, template_arg, args): +async def datetime_datetime_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: action_var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(action_var, config[CONF_ID]) diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 997aec3f69..b99b89259f 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -37,8 +37,6 @@ void DateEntity::publish_state() { #endif } -DateCall DateEntity::make_call() { return DateCall(this); } - void DateCall::validate_() { if (this->year_.has_value() && (this->year_ < 1970 || this->year_ > 3000)) { ESP_LOGE(TAG, "Year must be between 1970 and 3000"); diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index 8233e809a1..93ce1411f8 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -96,7 +96,9 @@ class DateCall { optional day_; }; -template class DateSetAction : public Action, public Parented { +inline DateCall DateEntity::make_call() { return DateCall(this); } + +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.cpp b/esphome/components/datetime/datetime_entity.cpp index a8e00d6eb3..8f180fd081 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -53,8 +53,6 @@ void DateTimeEntity::publish_state() { #endif } -DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } - ESPTime DateTimeEntity::state_as_esptime() const { ESPTime obj; obj.year = this->year_; diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index b1b8a77846..fec620b5ba 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,7 +121,9 @@ class DateTimeCall { optional second_; }; -template class DateTimeSetAction : public Action, public Parented { +inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } + +template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) @@ -136,7 +138,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.cpp b/esphome/components/datetime/time_entity.cpp index 1cc9eaf2fb..da4c9eb31e 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -33,8 +33,6 @@ void TimeEntity::publish_state() { #endif } -TimeCall TimeEntity::make_call() { return TimeCall(this); } - void TimeCall::validate_() { if (this->hour_.has_value() && this->hour_ > 23) { ESP_LOGE(TAG, "Hour must be between 0 and 23"); diff --git a/esphome/components/datetime/time_entity.h b/esphome/components/datetime/time_entity.h index 3f224684bb..736e26f4a7 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,7 +98,9 @@ class TimeCall { optional second_; }; -template class TimeSetAction : public Action, public Parented { +inline TimeCall TimeEntity::make_call() { return TimeCall(this); } + +template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) @@ -113,7 +115,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..a889d13329 100644 --- a/esphome/components/debug/__init__.py +++ b/esphome/components/debug/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] @@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CORE.using_zephyr: zephyr_add_prj_conf("HWINFO", True) # gdb thread support @@ -70,7 +71,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_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 7c01f9b54f..969cd840cf 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); ESP_LOGD(TAG, diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 1cc04dcbd8..55b29310a1 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -28,7 +28,7 @@ size_t DebugComponent::get_device_info_(std::span ESP_LOGD(TAG, "LibreTiny debug info:\n" " Version: %s\n" - " Chip: %s (%04x) @ %u MHz\n" + " Chip: %s (%04x) @ %" PRIu32 " MHz\n" " Chip ID: 0x%06" PRIX32 "\n" " Board: %s\n" " Flash: %" PRIu32 " KiB\n" @@ -38,7 +38,7 @@ size_t DebugComponent::get_device_info_(std::span lt_get_board_code(), flash_kib, ram_kib, reset_reason); pos = buf_append_str(buf, size, pos, "|Version: "); - pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10); + pos = buf_append_str(buf, size, pos, <_BANNER_STR[10]); pos = buf_append_str(buf, size, pos, "|Reset Reason: "); pos = buf_append_str(buf, size, pos, reset_reason); pos = buf_append_str(buf, size, pos, "|Chip Name: "); diff --git a/esphome/components/debug/debug_rp2040.cpp b/esphome/components/debug/debug_rp2.cpp similarity index 83% rename from esphome/components/debug/debug_rp2040.cpp rename to esphome/components/debug/debug_rp2.cpp index adc23dbf51..4ace4be0a3 100644 --- a/esphome/components/debug/debug_rp2040.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,16 +1,17 @@ #include "debug_component.h" -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include #include #if defined(PICO_RP2350) #include #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 +42,8 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } +// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here. +uint32_t DebugComponent::get_free_heap_() { return RAMAllocator().get_free_heap_size(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = ::rp2040.f_cpu(); + uint32_t cpu_freq = clock_get_hz(clk_sys); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index a018ce5c3b..72e2efebc2 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -111,7 +112,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if free_conf := config.get(CONF_FREE): diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index c69b8d9461..9d4fcc1b42 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ICON_CHIP, ICON_RESTART, ) +from esphome.types import ConfigType from . import ( # noqa: F401 pylint: disable=unused-import CONF_DEBUG_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) if CONF_DEVICE in config: diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 9666c8e507..dc03708645 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( CONF_SECOND, CONF_SLEEP_DURATION, CONF_TIME_ID, + CONF_TRIGGER_ID, CONF_WAKEUP_PIN, PLATFORM_BK72XX, PLATFORM_ESP32, @@ -37,7 +38,8 @@ from esphome.const import ( PLATFORM_NRF52, PlatformFramework, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType WAKEUP_PINS = { @@ -161,6 +163,11 @@ def validate_config(config: ConfigType) -> ConfigType: "You need to remove the global wakeup_pin_mode and define it per pin" ) if wakeup_pins: + if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: + raise cv.Invalid( + "Specify wakeup_pin_mode either at the top level under deep_sleep " + "or under the pin entry, not both" + ) wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) elif ( isinstance(config.get(CONF_WAKEUP_PIN), list) @@ -173,7 +180,7 @@ def validate_config(config: ConfigType) -> ConfigType: return config -def _validate_ex1_wakeup_mode(value): +def _validate_ex1_wakeup_mode(value: str) -> str: if value == "ALL_LOW": esp32.only_on_variant(supported=[VARIANT_ESP32], msg_prefix="ALL_LOW")(value) if value == "ANY_LOW": @@ -234,6 +241,15 @@ EXT1_WAKEUP_MODES = { } WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration") +WakeupCause = deep_sleep_ns.enum("WakeupCause") +WakeTrigger = deep_sleep_ns.class_( + "WakeTrigger", automation.Trigger.template(WakeupCause), cg.Component +) +Ext1WakeTrigger = deep_sleep_ns.class_( + "Ext1WakeTrigger", automation.Trigger.template(), cg.Component +) + +CONF_ON_WAKE = "on_wake" CONF_WAKEUP_PIN_MODE = "wakeup_pin_mode" CONF_ESP32_EXT1_WAKEUP = "esp32_ext1_wakeup" CONF_TOUCH_WAKEUP = "touch_wakeup" @@ -256,6 +272,22 @@ WAKEUP_PIN_SCHEMA = cv.Schema( } ) +EXT1_WAKEUP_PIN_SCHEMA = cv.Schema( + { + cv.Required(CONF_PIN): cv.All( + pins.internal_gpio_input_pin_schema, validate_pin_number_esp32 + ), + cv.Optional(CONF_ON_WAKE): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Ext1WakeTrigger)} + ), + } +) + +# Entries that are not in the {pin: ..., on_wake: ...} form are treated as a +# bare pin config (the original syntax, e.g. a plain "GPIO5" or {number: 5}). +validate_ext1_wakeup_pin = cv.maybe_simple_value(EXT1_WAKEUP_PIN_SCHEMA, key=CONF_PIN) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -282,8 +314,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_PINS): cv.ensure_list( - pins.internal_gpio_input_pin_schema, - validate_pin_number_esp32, + validate_ext1_wakeup_pin, ), cv.Required(CONF_MODE): cv.All( cv.enum(EXT1_WAKEUP_MODES, upper=True), @@ -292,6 +323,12 @@ CONFIG_SCHEMA = cv.All( } ), ), + cv.Optional(CONF_ON_WAKE): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(WakeTrigger)} + ), + ), cv.Optional(CONF_TOUCH_WAKEUP): cv.All( cv.only_on_esp32, esp32.only_on_variant( @@ -314,7 +351,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -323,7 +360,7 @@ async def to_code(config): if CONF_WAKEUP_PIN in config: pins_as_list = config.get(CONF_WAKEUP_PIN, []) if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins_(len(pins_as_list))) + cg.add(var.init_wakeup_pins(len(pins_as_list))) for item in pins_as_list: cg.add( var.add_wakeup_pin( @@ -362,16 +399,27 @@ async def to_code(config): ) cg.add(var.set_run_duration(wakeup_cause_to_run_duration)) - if CONF_ESP32_EXT1_WAKEUP in config: - conf = config[CONF_ESP32_EXT1_WAKEUP] + if (ext1_conf := config.get(CONF_ESP32_EXT1_WAKEUP)) is not None: mask = 0 - for pin in conf[CONF_PINS]: - mask |= 1 << pin[CONF_NUMBER] + for pin_conf in ext1_conf[CONF_PINS]: + number = pin_conf[CONF_PIN][CONF_NUMBER] + mask |= 1 << number + for wake_conf in pin_conf.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID], number) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") struct = cg.StructInitializer( - Ext1Wakeup, ("mask", mask), ("wakeup_mode", conf[CONF_MODE]) + Ext1Wakeup, ("mask", mask), ("wakeup_mode", ext1_conf[CONF_MODE]) ) cg.add(var.set_ext1_wakeup(struct)) + for wake_conf in config.get(CONF_ON_WAKE, []): + trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID]) + await cg.register_component(trigger, wake_conf) + await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf) + cg.add_define("USE_DEEP_SLEEP_ON_WAKE") + if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: @@ -416,7 +464,12 @@ DEEP_SLEEP_ENTER_SCHEMA = cv.All( DEEP_SLEEP_ENTER_SCHEMA, synchronous=True, ) -async def deep_sleep_enter_to_code(config, action_id, template_arg, args): +async def deep_sleep_enter_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) if CONF_SLEEP_DURATION in config: @@ -445,7 +498,12 @@ async def deep_sleep_enter_to_code(config, action_id, template_arg, args): automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), synchronous=True, ) -async def deep_sleep_action_to_code(config, action_id, template_arg, args): +async def deep_sleep_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 8dca32689b..2c97dc3211 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,22 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep.bk72xx"; +static const char *const TAG = "deep_sleep"; + +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (lt_get_reboot_reason()) { + case REBOOT_REASON_SLEEP_GPIO: + return WAKEUP_CAUSE_GPIO; + case REBOOT_REASON_SLEEP_RTC: + return WAKEUP_CAUSE_TIMER; + case REBOOT_REASON_SLEEP_USB: + return WAKEUP_CAUSE_UNKNOWN; + default: + return WAKEUP_CAUSE_NONE; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } @@ -15,15 +30,15 @@ void DeepSleepComponent::dump_config_platform_() { } } -bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pinItem) const { - return (pinItem.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pinItem.wakeup_pin != nullptr && - !this->sleep_duration_.has_value() && (pinItem.wakeup_level == get_real_pin_state_(*pinItem.wakeup_pin))); +bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pin_item) const { + return (pin_item.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pin_item.wakeup_pin != nullptr && + !this->sleep_duration_.has_value() && (pin_item.wakeup_level == get_real_pin_state_(*pin_item.wakeup_pin))); } bool DeepSleepComponent::prepare_to_sleep_() { - if (wakeup_pins_.size() > 0) { + if (!this->wakeup_pins_.empty()) { for (WakeUpPinItem &item : this->wakeup_pins_) { - if (pin_prevents_sleep_(item)) { + if (this->pin_prevents_sleep_(item)) { // Defer deep sleep until inactive if (!this->next_enter_deep_sleep_) { this->status_set_warning(); @@ -44,7 +59,7 @@ void DeepSleepComponent::deep_sleep_() { item.wakeup_level = !item.wakeup_level; } } - ESP_LOGI(TAG, "Wake-up on P%u %s (%d)", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", + ESP_LOGI(TAG, "Wake-up on P%u %s (%" PRId32 ")", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW", static_cast(item.wakeup_pin_mode)); } diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index e7ce70b60c..9a3e537e05 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -43,10 +43,6 @@ void DeepSleepComponent::loop() { this->begin_sleep(); } -void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } - -void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } - void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { this->next_enter_deep_sleep_ = true; @@ -76,8 +72,4 @@ void DeepSleepComponent::begin_sleep(bool manual) { float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; } -void DeepSleepComponent::prevent_deep_sleep() { this->prevent_ = true; } - -void DeepSleepComponent::allow_deep_sleep() { this->prevent_ = false; } - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 2df53f1540..208f88d707 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -60,6 +60,65 @@ struct WakeupCauseToRunDuration { #endif // USE_ESP32 +#ifdef USE_DEEP_SLEEP_ON_WAKE + +/// Why the device woke from deep sleep. Passed to on_wake automations. +enum WakeupCause : uint8_t { + /// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart). + WAKEUP_CAUSE_NONE = 0, + /// The device woke from deep sleep, but the source could not be identified. + WAKEUP_CAUSE_UNKNOWN, + /// The device was woken by the sleep timer. + WAKEUP_CAUSE_TIMER, + /// The device was woken by a GPIO pin (wakeup_pin or esp32_ext1_wakeup). + WAKEUP_CAUSE_GPIO, + /// The device was woken by a touch pad. + WAKEUP_CAUSE_TOUCH, +}; + +/// Return why the device woke from deep sleep. Implemented per platform. +WakeupCause get_wakeup_cause(); + +/** Setup priority of on_wake triggers. + * + * Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at + * their default priority (600), so on_wake automations can update state (e.g. globals) that + * on_boot automations then use. + */ +inline constexpr float ON_WAKE_TRIGGER_SETUP_PRIORITY = 700.0f; + +/// Fires once on boot when the device woke from deep sleep, with the wakeup cause. +class WakeTrigger : public Trigger, public Component { + public: + void setup() override { + const WakeupCause cause = get_wakeup_cause(); + if (cause != WAKEUP_CAUSE_NONE) { + this->trigger(cause); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } +}; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) +/// Fires once on boot when the device was woken from deep sleep by the given ext1 pin. +class Ext1WakeTrigger : public Trigger<>, public Component { + public: + explicit Ext1WakeTrigger(uint8_t pin) : pin_(pin) {} + void setup() override { + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT1 && + (esp_sleep_get_ext1_wakeup_status() & (1ULL << this->pin_))) { + this->trigger(); + } + } + float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; } + + protected: + uint8_t pin_; +}; +#endif + +#endif // USE_DEEP_SLEEP_ON_WAKE + template class EnterDeepSleepAction; template class PreventDeepSleepAction; @@ -70,10 +129,10 @@ template 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); + void set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; } #if defined(USE_ESP32) /** Set the pin to wake up to on the ESP32 once it's in deep sleep mode. * Use the inverted property to set the wakeup level. @@ -84,7 +143,7 @@ class DeepSleepComponent : public Component { #endif // USE_ESP32 #if defined(USE_BK72XX) - void init_wakeup_pins_(size_t capacity) { this->wakeup_pins_.init(capacity); } + void init_wakeup_pins(size_t capacity) { this->wakeup_pins_.init(capacity); } void add_wakeup_pin(InternalGPIOPin *wakeup_pin, WakeupPinMode wakeup_pin_mode) { this->wakeup_pins_.emplace_back(WakeUpPinItem{wakeup_pin, wakeup_pin_mode, !wakeup_pin->is_inverted()}); } @@ -96,8 +155,9 @@ 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) - void set_touch_wakeup(bool touch_wakeup); + !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) { this->touch_wakeup_ = touch_wakeup; } #endif // Set the duration in ms for how long the code should run before entering @@ -106,7 +166,7 @@ class DeepSleepComponent : public Component { #endif // USE_ESP32 /// Set a duration in ms for how long the code should run before entering deep sleep mode. - void set_run_duration(uint32_t time_ms); + void set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; } void setup() override; void dump_config() override; @@ -116,8 +176,8 @@ class DeepSleepComponent : public Component { /// Helper to enter deep sleep mode void begin_sleep(bool manual = false); - void prevent_deep_sleep(); - void allow_deep_sleep(); + void prevent_deep_sleep() { this->prevent_ = true; } + void allow_deep_sleep() { this->prevent_ = false; } protected: // Returns nullopt if no run duration is set. Otherwise, returns the run @@ -131,7 +191,7 @@ class DeepSleepComponent : public Component { bool should_teardown_(); #ifdef USE_BK72XX - bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const; + bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } #endif // USE_BK72XX @@ -161,7 +221,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 +293,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..3fa1a1f1ed 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 | | ✓ | | | @@ -30,6 +30,25 @@ namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + switch (esp_sleep_get_wakeup_cause()) { + case ESP_SLEEP_WAKEUP_EXT0: + case ESP_SLEEP_WAKEUP_EXT1: + case ESP_SLEEP_WAKEUP_GPIO: + return WAKEUP_CAUSE_GPIO; + case ESP_SLEEP_WAKEUP_TIMER: + return WAKEUP_CAUSE_TIMER; + case ESP_SLEEP_WAKEUP_TOUCHPAD: + return WAKEUP_CAUSE_TOUCH; + case ESP_SLEEP_WAKEUP_UNDEFINED: + return WAKEUP_CAUSE_NONE; + default: + return WAKEUP_CAUSE_UNKNOWN; + } +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { if (this->wakeup_cause_to_run_duration_.has_value()) { esp_sleep_wakeup_cause_t wakeup_cause = esp_sleep_get_wakeup_cause(); @@ -55,11 +74,6 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) { void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; } #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) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } -#endif - void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { wakeup_cause_to_run_duration_ = wakeup_cause_to_run_duration; } @@ -99,7 +113,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 +137,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 +169,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_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 9239a7fb31..2b98f4b855 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -3,10 +3,27 @@ #include +#ifdef USE_DEEP_SLEEP_ON_WAKE +extern "C" { +#include +} +#endif + namespace esphome::deep_sleep { static const char *const TAG = "deep_sleep"; +#ifdef USE_DEEP_SLEEP_ON_WAKE +WakeupCause get_wakeup_cause() { + // The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST). + // NOLINTNEXTLINE(readability-static-accessed-through-instance) + if (ESP.getResetInfoPtr()->reason == REASON_DEEP_SLEEP_AWAKE) { + return WAKEUP_CAUSE_TIMER; + } + return WAKEUP_CAUSE_NONE; +} +#endif // USE_DEEP_SLEEP_ON_WAKE + optional DeepSleepComponent::get_run_duration_() const { return this->run_duration_; } void DeepSleepComponent::dump_config_platform_() {} 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/climate.py b/esphome/components/delonghi/climate.py index 63576f032d..919bf7b806 100644 --- a/esphome/components/delonghi/climate.py +++ b/esphome/components/delonghi/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -9,5 +10,5 @@ DelonghiClimate = delonghi_ns.class_("DelonghiClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DelonghiClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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/__init__.py b/esphome/components/demo/__init__.py index 2af0c18c18..75feaa65af 100644 --- a/esphome/components/demo/__init__.py +++ b/esphome/components/demo/__init__.py @@ -55,6 +55,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType AUTO_LOAD = [ "alarm_control_panel", @@ -550,7 +551,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: for conf in config[CONF_ALARM_CONTROL_PANELS]: var = await alarm_control_panel.new_alarm_control_panel(conf) cg.add(var.set_type(conf[CONF_TYPE])) 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/dew_point/sensor.py b/esphome/components/dew_point/sensor.py index 4fee095602..555fdef289 100644 --- a/esphome/components/dew_point/sensor.py +++ b/esphome/components/dew_point/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType DEPENDENCIES = ["sensor"] @@ -35,7 +36,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index 7796f5d891..d589381461 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_LOOP import esphome.config_validation as cv from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME @@ -15,7 +16,6 @@ DFPlayerIsPlayingCondition = dfplayer_ns.class_( MULTI_CONF = True CONF_FOLDER = "folder" -CONF_LOOP = "loop" CONF_EQ_PRESET = "eq_preset" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" 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/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 943c510279..51562f923c 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_SENSITIVITY +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@niklasweber"] DEPENDENCIES = ["uart"] @@ -38,7 +43,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -54,14 +59,19 @@ async def to_code(config): ), synchronous=True, ) -async def dfrobot_sen0395_reset_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -def range_segment_list(input): +def range_segment_list(input: Any) -> list: """Validate input is a list of ranges which can be used to configure the dfrobot mmwave radar A list of segments should be provided. A minimum of one segment is required and a maximum of @@ -154,7 +164,12 @@ MMWAVE_SETTINGS_SCHEMA = cv.Schema( MMWAVE_SETTINGS_SCHEMA, synchronous=True, ) -async def dfrobot_sen0395_settings_to_code(config, action_id, template_arg, args): +async def dfrobot_sen0395_settings_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) 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/binary_sensor.py b/esphome/components/dfrobot_sen0395/binary_sensor.py index 193ef925a4..e299c35a42 100644 --- a/esphome/components/dfrobot_sen0395/binary_sensor.py +++ b/esphome/components/dfrobot_sen0395/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_MOTION +from esphome.types import ConfigType from . import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -16,7 +17,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) binary_sens = await binary_sensor.new_binary_sensor(config) 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/__init__.py b/esphome/components/dfrobot_sen0395/switch/__init__.py index 8e492080de..22aaa1640c 100644 --- a/esphome/components/dfrobot_sen0395/switch/__init__.py +++ b/esphome/components/dfrobot_sen0395/switch/__init__.py @@ -3,6 +3,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType from .. import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_DFROBOT_SEN0395_ID]) var = await switch.new_switch(config) await cg.register_component(var, config) 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/dht/sensor.py b/esphome/components/dht/sensor.py index d907495ba2..7376adb287 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_PERCENT, ) from esphome.cpp_helpers import gpio_pin_expression +from esphome.types import ConfigType dht_ns = cg.esphome_ns.namespace("dht") DHTModel = dht_ns.enum("DHTModel") @@ -53,7 +54,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/dht12/sensor.py b/esphome/components/dht12/sensor.py index eb93cbae2c..2bc6e94515 100644 --- a/esphome/components/dht12/sensor.py +++ b/esphome/components/dht12/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -40,7 +41,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 744b5d16c4..7a66da11f2 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -3,11 +3,18 @@ from dataclasses import dataclass from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg -from esphome.components.const import KEY_METADATA +from esphome.components.const import ( + BYTE_ORDER_BIG, + CONF_BYTE_ORDER, + CONF_DRAW_ROUNDING, + KEY_METADATA, +) import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, + CONF_DIMENSIONS, CONF_FROM, + CONF_HEIGHT, CONF_ID, CONF_LAMBDA, CONF_PAGE_ID, @@ -16,10 +23,11 @@ from esphome.const import ( CONF_TO, CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, + CONF_WIDTH, SCHEDULER_DONT_RUN, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObj +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority +from esphome.final_validate import full_config DOMAIN = "display" IS_PLATFORM_COMPONENT = True @@ -159,29 +167,97 @@ async def setup_display_core_(var, config): class DisplayMetaData: width: int = 0 height: int = 0 - has_writer: bool = False has_hardware_rotation: bool = False + byte_order: str = BYTE_ORDER_BIG + has_writer: bool = False + rotation: int = 0 + draw_rounding: int = 0 + + +def _get_metadata_list() -> list[tuple]: + """Get the raw metadata list. Each entry is (id, DisplayMetaData).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, []) def get_all_display_metadata() -> dict[str, DisplayMetaData]: - """Get all display metadata.""" - return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + """Get all display metadata as a dict keyed by resolved ID strings. + + Must not be called before IDs have been finalised. + """ + entries = _get_metadata_list() + assert all(id_.id is not None for id_, _ in entries), ( + "get_all_display_metadata called before display IDs have been resolved" + ) + return {id_.id: meta for id_, meta in entries} -def get_display_metadata(display_id: str) -> DisplayMetaData | None: - """Get display metadata by ID for use by other components.""" - return get_all_display_metadata().get(display_id, DisplayMetaData()) +def get_display_metadata(display_id: ID) -> DisplayMetaData: + """Get display metadata by ID object + + Must not be called before IDs have been finalised. + """ + for id_, meta in _get_metadata_list(): + if id_ is display_id: + return meta + assert id_.id is not None, ( + "get_display_metadata called before display IDs have been resolved" + ) + if id_.id == display_id.id: + return meta + # No metadata found, display driver may not yet support it. + # Read the raw config to populate the returned data + global_config = full_config.get() + path = global_config.get_path_for_id(display_id)[:-1] + disp_config = global_config.get_config_for_path(path) + dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0)) + if isinstance(dimensions, dict): + dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0)) + elif not isinstance(dimensions, tuple) or len(dimensions) != 2: + dimensions = (0, 0) + + meta = DisplayMetaData( + width=dimensions[0], + height=dimensions[1], + has_hardware_rotation=False, + byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED), + has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True + or disp_config.get(CONF_PAGES) is not None + or disp_config.get(CONF_LAMBDA) is not None + or disp_config.get(CONF_SHOW_TEST_CARD) is True, + rotation=disp_config.get(CONF_ROTATION, 0), + draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0), + ) + _get_metadata_list().append((display_id, meta)) + return meta def add_metadata( - id: str | MockObj, + id: ID, width: int, height: int, - has_writer: bool, has_hardware_rotation: bool = False, + byte_order: str = BYTE_ORDER_BIG, + has_writer: bool = False, + rotation: int = 0, + draw_rounding: int = 0, ): - get_all_display_metadata()[str(id)] = DisplayMetaData( - width, height, has_writer, has_hardware_rotation + entries = _get_metadata_list() + assert not any(existing_id is id for existing_id, _ in entries), ( + f"Duplicate display metadata for ID {id}" + ) + entries.append( + ( + id, + DisplayMetaData( + width=width, + height=height, + has_hardware_rotation=has_hardware_rotation, + byte_order=byte_order, + has_writer=has_writer, + rotation=rotation, + draw_rounding=draw_rounding, + ), + ) ) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index cd2d2143f5..c2d45dbb60 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -1,4 +1,5 @@ #include "display.h" +#include #include #include #include "display_color_utils.h" @@ -41,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); @@ -227,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 @@ -238,7 +239,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int lhline_width = -(dxmax - dxmin) + 1; if (progress >= 50) { if (float(dymax) < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); } else { upd_dxmax = -dxmax; } @@ -253,7 +254,7 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, } } else { if (float(dymin) > float(-dxmin) * tan_a) { - upd_dxmin = ceil(float(dymin) / tan_a); + upd_dxmin = std::ceil(float(dymin) / tan_a); } else { upd_dxmin = -dxmin; } @@ -268,12 +269,12 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int hline_width = 2 * (-dxmax) + 1; if (progress >= 50) { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax + upd_dxmax + 1; } } else { if (dymax < float(-dxmax) * tan_a) { - upd_dxmax = ceil(float(dymax) / tan_a); + upd_dxmax = std::ceil(float(dymax) / tan_a); hline_width = -dxmax - upd_dxmax + 1; } else { hline_width = 0; @@ -443,17 +444,17 @@ 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; - *vertex_x = (int) round(cos(vertex_angle) * radius) + center_x; - *vertex_y = (int) round(sin(vertex_angle) * radius) + center_y; + 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; } } @@ -684,9 +685,6 @@ void Display::show_page(DisplayPage *page) { } } -void Display::show_next_page() { this->page_->show_next(); } -void Display::show_prev_page() { this->page_->show_prev(); } - void Display::do_update_() { if (this->auto_clear_enabled_) { this->clear(); @@ -891,9 +889,6 @@ void DisplayPage::show_prev() { this->prev_->show(); } -void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; } -void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; } -void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &DisplayPage::get_writer() const { return this->writer_; } const LogString *text_align_to_string(TextAlign textalign) { diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 6d0b7acfe8..a9ffda422d 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -796,15 +796,15 @@ class Display : public PollingComponent { bool show_test_card_{false}; }; -class DisplayPage { +class DisplayPage final { public: DisplayPage(display_writer_t writer); void show(); void show_next(); void show_prev(); - void set_parent(Display *parent); - void set_prev(DisplayPage *prev); - void set_next(DisplayPage *next); + void set_parent(Display *parent) { this->parent_ = parent; } + void set_prev(DisplayPage *prev) { this->prev_ = prev; } + void set_next(DisplayPage *next) { this->next_ = next; } const display_writer_t &get_writer() const; protected: @@ -814,7 +814,10 @@ class DisplayPage { DisplayPage *next_{nullptr}; }; -template class DisplayPageShowAction : public Action { +inline void Display::show_next_page() { this->page_->show_next(); } +inline void Display::show_prev_page() { this->page_->show_prev(); } + +template class DisplayPageShowAction final : public Action { public: TEMPLATABLE_VALUE(DisplayPage *, page) @@ -826,7 +829,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 +838,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 +847,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 +859,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/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c..2120abe5f7 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -3,6 +3,7 @@ import re from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import CONF_LABEL from esphome.components.number import Number from esphome.components.select import Select from esphome.components.switch import Switch @@ -30,7 +31,6 @@ display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") CONF_ROTARY = "rotary" CONF_JOYSTICK = "joystick" -CONF_LABEL = "label" CONF_MENU = "menu" CONF_BACK = "back" CONF_SELECT = "select" 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 c22ab7b552..00a1694cc3 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,57 +1,256 @@ -import esphome.codegen as cg -from esphome.components import uart -import esphome.config_validation as cv -from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +import logging +import re +from typing import Any -CODEOWNERS = ["@SimonFischer04"] +import esphome.codegen as cg +from esphome.components import esp32, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_PATTERN, + CONF_PRIORITY, + CONF_RECEIVE_TIMEOUT, +) +from esphome.core import CORE +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" +CONF_AUTH_KEY = "auth_key" +CONF_OBIS_CODE = "obis_code" +CONF_CUSTOM_PATTERNS = "custom_patterns" +CONF_SKIP_CRC = "skip_crc" +CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" -PROVIDERS = {"generic": 0, "netznoe": 1} - dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) -def validate_key(value): - value = cv.string_strict(value) - if len(value) != 32: - raise cv.Invalid("Decryption key must be 32 hex characters (16 bytes)") - try: - return [int(value[i : i + 2], 16) for i in range(0, 32, 2)] - except ValueError as exc: - raise cv.Invalid("Decryption key must be hex values from 00 to FF") from exc +def obis_code(value: Any) -> str: + # Normalize the OBIS code to the strict A.B.C.D.E.F format + bytes_list = parse_obis_code_bytes(value) + return ".".join(str(b) for b in bytes_list) +def parse_obis_code_bytes(value: Any) -> list[int]: + value = cv.string(value) + normalized = re.sub(r"[\-\:\*]", ".", value) + parts = normalized.split(".") + if len(parts) < 5 or len(parts) > 6: + raise cv.Invalid("OBIS code must have 5 or 6 parts") + try: + bytes_list = [int(p) for p in parts] + except ValueError as exc: + raise cv.Invalid("OBIS code parts must be integers") from exc + for b in bytes_list: + if b < 0 or b > 255: + raise cv.Invalid("OBIS code parts must be between 0 and 255") + if len(bytes_list) == 5: + bytes_list.append(255) + return bytes_list + + +def custom_pattern_dict(value: Any) -> ConfigType: + if isinstance(value, str): + return {CONF_PATTERN: value} + return value + + +def validate_custom_pattern(value: ConfigType) -> ConfigType: + if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: + raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") + return value + + +def validate_provider_deprecation(config: ConfigType) -> ConfigType: + if CONF_PROVIDER in config: + provider = str(config[CONF_PROVIDER]).lower() + if provider == "netznoe": + _LOGGER.warning( + "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " + "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" + "Please remove the 'provider' key and explicitly replace it with the following:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n' + ) + patterns = config.get(CONF_CUSTOM_PATTERNS, []) + + # Ensure "L, TSTR" for MeterID is present + if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): + patterns.append( + { + CONF_PATTERN: "L, TSTR", + CONF_NAME: "MeterID", + CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], + CONF_PRIORITY: 0, + } + ) + + # Ensure "F, TDTM" for DateTime is present + if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): + patterns.append( + { + CONF_PATTERN: "F, TDTM", + CONF_NAME: "DateTime", + CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], + CONF_PRIORITY: 0, + } + ) + + config[CONF_CUSTOM_PATTERNS] = patterns + else: + _LOGGER.warning( + "The 'provider' option is deprecated and will be removed in 2026.11.0. " + "The dlms_parser library now handles quirks dynamically. " + "Please remove this option from your configuration." + ) + return config + + +CUSTOM_PATTERN_SCHEMA = cv.All( + custom_pattern_dict, + cv.Schema( + { + cv.Required(CONF_PATTERN): cv.string, + cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_PRIORITY, default=0): cv.int_, + cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + } + ), + validate_custom_pattern, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), - cv.Required(CONF_DECRYPTION_KEY): validate_key, - cv.Optional(CONF_PROVIDER, default="generic"): cv.enum( - PROVIDERS, lower=True - ), + 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, + cv.Optional( + CONF_RECEIVE_TIMEOUT, default="1000ms" + ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP8266, PLATFORM_ESP32]), + validate_provider_deprecation, ) -FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dlms_meter", baud_rate=2400, require_rx=True -) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) +async def to_code(config: ConfigType) -> None: + dec_key_expr = cg.RawExpression("std::nullopt") + if dec_key := config.get(CONF_DECRYPTION_KEY): + key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] + dec_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + auth_key_expr = cg.RawExpression("std::nullopt") + if auth_key := config.get(CONF_AUTH_KEY): + key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] + auth_key_expr = cg.RawExpression( + f"std::array{{{', '.join(key_bytes)}}}" + ) + + patterns = [] + if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): + for p in custom_patterns: + name_expr = cg.RawExpression("std::nullopt") + if name_val := p.get(CONF_NAME): + name_expr = name_val + + if obis_vals := p.get(CONF_DEFAULT_OBIS): + obis_expr = cg.RawExpression( + f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" + ) + else: + obis_expr = cg.RawExpression("std::nullopt") + + patterns.append( + cg.ArrayInitializer( + p[CONF_PATTERN], + name_expr, + p.get(CONF_PRIORITY, 0), + obis_expr, + ) + ) + + patterns_expr = ( + cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") + ) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_RECEIVE_TIMEOUT], + config[CONF_SKIP_CRC], + dec_key_expr, + auth_key_expr, + patterns_expr, + ) + + hub_id = config[CONF_ID].id + + sensor_count = 0 + for sens_conf in CORE.config.get("sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + sensor_count += 1 + else: + from .sensor import NUMERIC_KEYS + + sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) + + text_sensor_count = 0 + for sens_conf in CORE.config.get("text_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + if CONF_OBIS_CODE in sens_conf: + text_sensor_count += 1 + else: + from .text_sensor import TEXT_KEYS + + text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) + + binary_sensor_count = 0 + for sens_conf in CORE.config.get("binary_sensor", []): + if ( + sens_conf.get("platform") == "dlms_meter" + and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id + ): + binary_sensor_count += 1 + + cg.add_define("DLMS_MAX_SENSORS", sensor_count) + cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) + cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) + await cg.register_component(var, config) await uart.register_uart_device(var, config) - key = ", ".join(str(b) for b in config[CONF_DECRYPTION_KEY]) - cg.add(var.set_decryption_key(cg.RawExpression(f"{{{key}}}"))) - cg.add(var.set_provider(PROVIDERS[config[CONF_PROVIDER]])) + + if CORE.is_esp32: + esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + else: + cg.add_library("esphome/dlms_parser", "1.1.0") diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py new file mode 100644 index 0000000000..a15e58b957 --- /dev/null +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -0,0 +1,21 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code + +DEPENDENCIES = ["dlms_meter"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Required(CONF_OBIS_CODE): obis_code, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) diff --git a/esphome/components/dlms_meter/dlms.h b/esphome/components/dlms_meter/dlms.h deleted file mode 100644 index a3d8f62ce6..0000000000 --- a/esphome/components/dlms_meter/dlms.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+-------------------------------+ -| Ciphering Service | -+-------------------------------+ -| System Title Length | -+-------------------------------+ -| | -| | -| | -| System | -| Title | -| | -| | -| | -+-------------------------------+ -| Length | (1 or 3 Bytes) -+-------------------------------+ -| Security Control Byte | -+-------------------------------+ -| | -| Frame | -| Counter | -| | -+-------------------------------+ -| | -~ ~ - Encrypted Payload -~ ~ -| | -+-------------------------------+ - -Ciphering Service: 0xDB (General-Glo-Ciphering) -System Title Length: 0x08 -System Title: Unique ID of meter -Length: 1 Byte=Length <= 127, 3 Bytes=Length > 127 (0x82 & 2 Bytes length) -Security Control Byte: -- Bit 3…0: Security_Suite_Id -- Bit 4: "A" subfield: indicates that authentication is applied -- Bit 5: "E" subfield: indicates that encryption is applied -- Bit 6: Key_Set subfield: 0 = Unicast, 1 = Broadcast -- Bit 7: Indicates the use of compression. - */ - -static constexpr uint8_t DLMS_HEADER_LENGTH = 16; -static constexpr uint8_t DLMS_HEADER_EXT_OFFSET = 2; // Extra offset for extended length header -static constexpr uint8_t DLMS_CIPHER_OFFSET = 0; -static constexpr uint8_t DLMS_SYST_OFFSET = 1; -static constexpr uint8_t DLMS_LENGTH_OFFSET = 10; -static constexpr uint8_t TWO_BYTE_LENGTH = 0x82; -static constexpr uint8_t DLMS_LENGTH_CORRECTION = 5; // Header bytes included in length field -static constexpr uint8_t DLMS_SECBYTE_OFFSET = 11; -static constexpr uint8_t DLMS_FRAMECOUNTER_OFFSET = 12; -static constexpr uint8_t DLMS_FRAMECOUNTER_LENGTH = 4; -static constexpr uint8_t DLMS_PAYLOAD_OFFSET = 16; -static constexpr uint8_t GLO_CIPHERING = 0xDB; -static constexpr uint8_t DATA_NOTIFICATION = 0x0F; -static constexpr uint8_t TIMESTAMP_DATETIME = 0x0C; -static constexpr uint16_t MAX_MESSAGE_LENGTH = 512; // Maximum size of message (when having 2 bytes length in header). - -// Provider specific quirks -static constexpr uint8_t NETZ_NOE_MAGIC_BYTE = 0x81; // Magic length byte used by Netz NOE -static constexpr uint8_t NETZ_NOE_EXPECTED_MESSAGE_LENGTH = 0xF8; -static constexpr uint8_t NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE = 0x20; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index b732e71d24..bdbf798df5 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,516 +1,236 @@ #include "dlms_meter.h" +#include "esphome/core/log.h" -#include - -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) -#include -#elif defined(USE_ESP32) -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -#include -#else -#include "mbedtls/esp_config.h" -#include "mbedtls/gcm.h" -#endif -#endif +#include namespace esphome::dlms_meter { -static constexpr const char *TAG = "dlms_meter"; +static const char *const TAG = "dlms_meter"; +static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { + std::array buf; + vsnprintf(buf.data(), buf.size(), fmt, args); + switch (level) { + case dlms_parser::LogLevel::ERROR: + ESP_LOGE(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::WARNING: + ESP_LOGW(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::INFO: + ESP_LOGI(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERBOSE: + ESP_LOGV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::VERY_VERBOSE: + ESP_LOGVV(TAG, "%s", buf.data()); + break; + case dlms_parser::LogLevel::DEBUG: + ESP_LOGD(TAG, "%s", buf.data()); + break; + } +} + +DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns) + : receive_timeout_ms_(receive_timeout_ms), + skip_crc_check_(skip_crc_check), + custom_patterns_(std::move(custom_patterns)), + parser_(&decryptor_) { + dlms_parser::Logger::set_log_function(log_callback); + + if (decryption_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); + if (opt_key) { + this->parser_.set_decryption_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); + } +#endif + } + + if (authentication_key.has_value()) { +#ifdef DLMS_METER_NO_CRYPTO + ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); +#else + auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); + if (opt_key) { + this->parser_.set_authentication_key(*opt_key); + } else { + ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); + } +#endif + } + + this->parser_.set_skip_crc_check(this->skip_crc_check_); + + this->parser_.load_default_patterns(); + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, + pattern.default_obis.value()); + } else if (pattern.name.has_value()) { + this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); + } else { + this->parser_.register_pattern(pattern.pattern.c_str()); + } + } +} + +void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); } void DlmsMeterComponent::dump_config() { - const char *provider_name = this->provider_ == PROVIDER_NETZNOE ? "Netz NOE" : "Generic"; - ESP_LOGCONFIG(TAG, - "DLMS Meter:\n" - " Provider: %s\n" - " Read Timeout: %" PRIu32 " ms", - provider_name, this->read_timeout_); -#define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); - DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) -#define DLMS_METER_LOG_TEXT_SENSOR(s) LOG_TEXT_SENSOR(" ", #s, this->s##_text_sensor_); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_LOG_TEXT_SENSOR, ) + ESP_LOGCONFIG(TAG, "DLMS Meter:"); + ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_); + ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_)); + + for (const auto &pattern : this->custom_patterns_) { + if (pattern.default_obis.has_value() && pattern.name.has_value()) { + const auto &obis = pattern.default_obis.value(); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", + pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], + obis[3], obis[4], obis[5]); + } else if (pattern.name.has_value()) { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), + pattern.name->c_str(), pattern.priority); + } else { + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); + } + } + +#ifdef USE_SENSOR + for (const auto &entry : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_TEXT_SENSOR + for (const auto &entry : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif +#ifdef USE_BINARY_SENSOR + for (const auto &entry : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); + ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + } +#endif } void DlmsMeterComponent::loop() { - // Read while data is available, netznoe uses two frames so allow 2x max frame length - size_t avail = this->available(); - if (avail > 0) { - size_t remaining = MBUS_MAX_FRAME_LENGTH * 2 - this->receive_buffer_.size(); - if (remaining == 0) { - ESP_LOGW(TAG, "Receive buffer full, dropping remaining bytes"); - } else { - // Read all available bytes in batches to reduce UART call overhead. - // Cap reads to remaining buffer capacity. - if (avail > remaining) { - avail = remaining; - } - uint8_t buf[64]; - while (avail > 0) { - size_t to_read = std::min(avail, sizeof(buf)); - if (!this->read_array(buf, to_read)) { - break; - } - avail -= to_read; - this->receive_buffer_.insert(this->receive_buffer_.end(), buf, buf + to_read); - this->last_read_ = millis(); - } - } - } - - if (!this->receive_buffer_.empty() && millis() - this->last_read_ > this->read_timeout_) { - this->mbus_payload_.clear(); - if (!this->parse_mbus_(this->mbus_payload_)) - return; - - uint16_t message_length; - uint8_t systitle_length; - uint16_t header_offset; - if (!this->parse_dlms_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - - if (message_length < DECODER_START_OFFSET || message_length > MAX_MESSAGE_LENGTH) { - ESP_LOGE(TAG, "DLMS: Message length invalid: %u", message_length); - this->receive_buffer_.clear(); - return; - } - - // Decrypt in place and then decode the OBIS codes - if (!this->decrypt_(this->mbus_payload_, message_length, systitle_length, header_offset)) - return; - this->decode_obis_(&this->mbus_payload_[header_offset + DLMS_PAYLOAD_OFFSET], message_length); + this->read_rx_buffer_(); + if (this->bytes_accumulated_ > 0 && + App.get_loop_component_start_time() - this->last_rx_char_time_ > this->receive_timeout_ms_) { + this->process_frame_(); } } -bool DlmsMeterComponent::parse_mbus_(std::vector &mbus_payload) { - ESP_LOGV(TAG, "Parsing M-Bus frames"); - uint16_t frame_offset = 0; // Offset is used if the M-Bus message is split into multiple frames - - while (frame_offset < this->receive_buffer_.size()) { - // Ensure enough bytes remain for the minimal intro header before accessing indices - if (this->receive_buffer_.size() - frame_offset < MBUS_HEADER_INTRO_LENGTH) { - ESP_LOGE(TAG, "MBUS: Not enough data for frame header (need %d, have %d)", MBUS_HEADER_INTRO_LENGTH, - (this->receive_buffer_.size() - frame_offset)); - this->receive_buffer_.clear(); - return false; - } - - // Check start bytes - if (this->receive_buffer_[frame_offset + MBUS_START1_OFFSET] != START_BYTE_LONG_FRAME || - this->receive_buffer_[frame_offset + MBUS_START2_OFFSET] != START_BYTE_LONG_FRAME) { - ESP_LOGE(TAG, "MBUS: Start bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - // Both length bytes must be identical - if (this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET] != - this->receive_buffer_[frame_offset + MBUS_LENGTH2_OFFSET]) { - ESP_LOGE(TAG, "MBUS: Length bytes do not match"); - this->receive_buffer_.clear(); - return false; - } - - uint8_t frame_length = this->receive_buffer_[frame_offset + MBUS_LENGTH1_OFFSET]; // Get length of this frame - - // Check if received data is enough for the given frame length - if (this->receive_buffer_.size() - frame_offset < - frame_length + 3) { // length field inside packet does not account for second start- + checksum- + stop- byte - ESP_LOGE(TAG, "MBUS: Frame too big for received data"); - this->receive_buffer_.clear(); - return false; - } - - // Ensure we have full frame (header + payload + checksum + stop byte) before accessing stop byte - size_t required_total = - frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH; // payload + header + 2 footer bytes - if (this->receive_buffer_.size() - frame_offset < required_total) { - ESP_LOGE(TAG, "MBUS: Incomplete frame (need %d, have %d)", (unsigned int) required_total, - this->receive_buffer_.size() - frame_offset); - this->receive_buffer_.clear(); - return false; - } - - if (this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH + MBUS_FOOTER_LENGTH - 1] != - STOP_BYTE) { - ESP_LOGE(TAG, "MBUS: Invalid stop byte"); - this->receive_buffer_.clear(); - return false; - } - - // Verify checksum: sum of all bytes starting at MBUS_HEADER_INTRO_LENGTH, take last byte - uint8_t checksum = 0; // use uint8_t so only the 8 least significant bits are stored - for (uint16_t i = 0; i < frame_length; i++) { - checksum += this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + i]; - } - if (checksum != this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]) { - ESP_LOGE(TAG, "MBUS: Invalid checksum: %x != %x", checksum, - this->receive_buffer_[frame_offset + frame_length + MBUS_HEADER_INTRO_LENGTH]); - this->receive_buffer_.clear(); - return false; - } - - mbus_payload.insert(mbus_payload.end(), &this->receive_buffer_[frame_offset + MBUS_FULL_HEADER_LENGTH], - &this->receive_buffer_[frame_offset + MBUS_HEADER_INTRO_LENGTH + frame_length]); - - frame_offset += MBUS_HEADER_INTRO_LENGTH + frame_length + MBUS_FOOTER_LENGTH; +void DlmsMeterComponent::flush_rx_buffer_() { + while (this->available()) { + this->read(); } - return true; } -bool DlmsMeterComponent::parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, - uint8_t &systitle_length, uint16_t &header_offset) { - ESP_LOGV(TAG, "Parsing DLMS header"); - if (mbus_payload.size() < DLMS_HEADER_LENGTH + DLMS_HEADER_EXT_OFFSET) { - ESP_LOGE(TAG, "DLMS: Payload too short"); - this->receive_buffer_.clear(); - return false; +void DlmsMeterComponent::read_rx_buffer_() { + int available = this->available(); + if (available == 0) + return; + + if (this->bytes_accumulated_ + available > this->rx_buffer_.size()) { + ESP_LOGW(TAG, "RX Buffer overflow. Frame too large! Dropping frame."); + this->bytes_accumulated_ = 0; + + this->flush_rx_buffer_(); + return; } - if (mbus_payload[DLMS_CIPHER_OFFSET] != GLO_CIPHERING) { // Only general-glo-ciphering is supported (0xDB) - ESP_LOGE(TAG, "DLMS: Unsupported cipher"); - this->receive_buffer_.clear(); - return false; + bool success = this->read_array(this->rx_buffer_.data() + this->bytes_accumulated_, available); + if (!success) { + ESP_LOGW(TAG, "UART read failed. Dropping frame."); + this->bytes_accumulated_ = 0; + this->flush_rx_buffer_(); + return; } - systitle_length = mbus_payload[DLMS_SYST_OFFSET]; + this->bytes_accumulated_ += available; - if (systitle_length != 0x08) { // Only system titles with length of 8 are supported - ESP_LOGE(TAG, "DLMS: Unsupported system title length"); - this->receive_buffer_.clear(); - return false; - } - - message_length = mbus_payload[DLMS_LENGTH_OFFSET]; - header_offset = 0; - - if (this->provider_ == PROVIDER_NETZNOE) { - // for some reason EVN seems to set the standard "length" field to 0x81 and then the actual length is in the next - // byte. Check some bytes to see if received data still matches expectation - if (message_length == NETZ_NOE_MAGIC_BYTE && - mbus_payload[DLMS_LENGTH_OFFSET + 1] == NETZ_NOE_EXPECTED_MESSAGE_LENGTH && - mbus_payload[DLMS_LENGTH_OFFSET + 2] == NETZ_NOE_EXPECTED_SECURITY_CONTROL_BYTE) { - message_length = mbus_payload[DLMS_LENGTH_OFFSET + 1]; - header_offset = 1; - } else { - ESP_LOGE(TAG, "Wrong Length - Security Control Byte sequence detected for provider EVN"); - } - } else { - if (message_length == TWO_BYTE_LENGTH) { - message_length = encode_uint16(mbus_payload[DLMS_LENGTH_OFFSET + 1], mbus_payload[DLMS_LENGTH_OFFSET + 2]); - header_offset = DLMS_HEADER_EXT_OFFSET; - } - } - if (message_length < DLMS_LENGTH_CORRECTION) { - ESP_LOGE(TAG, "DLMS: Message length too short: %u", message_length); - this->receive_buffer_.clear(); - return false; - } - message_length -= DLMS_LENGTH_CORRECTION; // Correct message length due to part of header being included in length - - if (mbus_payload.size() - DLMS_HEADER_LENGTH - header_offset != message_length) { - ESP_LOGV(TAG, "DLMS: Length mismatch - payload=%d, header=%d, offset=%d, message=%d", mbus_payload.size(), - DLMS_HEADER_LENGTH, header_offset, message_length); - ESP_LOGE(TAG, "DLMS: Message has invalid length"); - this->receive_buffer_.clear(); - return false; - } - - if (mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != 0x21 && - mbus_payload[header_offset + DLMS_SECBYTE_OFFSET] != - 0x20) { // Only certain security suite is supported (0x21 || 0x20) - ESP_LOGE(TAG, "DLMS: Unsupported security control byte"); - this->receive_buffer_.clear(); - return false; - } - - return true; + this->last_rx_char_time_ = App.get_loop_component_start_time(); } -bool DlmsMeterComponent::decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset) { - ESP_LOGV(TAG, "Decrypting payload"); - uint8_t iv[12]; // Reserve space for the IV, always 12 bytes - // Copy system title to IV (System title is before length; no header offset needed!) - // Add 1 to the offset in order to skip the system title length byte - memcpy(&iv[0], &mbus_payload[DLMS_SYST_OFFSET + 1], systitle_length); - memcpy(&iv[8], &mbus_payload[header_offset + DLMS_FRAMECOUNTER_OFFSET], - DLMS_FRAMECOUNTER_LENGTH); // Copy frame counter to IV +void DlmsMeterComponent::process_frame_() { + ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - uint8_t *payload_ptr = &mbus_payload[header_offset + DLMS_PAYLOAD_OFFSET]; + auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + this->on_data_(obis_code, float_val, str_val, is_numeric); + }; -#if defined(USE_ESP8266_FRAMEWORK_ARDUINO) - br_gcm_context gcm_ctx; - br_aes_ct_ctr_keys bc; - br_aes_ct_ctr_init(&bc, this->decryption_key_.data(), this->decryption_key_.size()); - br_gcm_init(&gcm_ctx, &bc.vtable, br_ghash_ctmul32); - br_gcm_reset(&gcm_ctx, iv, sizeof(iv)); - br_gcm_flip(&gcm_ctx); - br_gcm_run(&gcm_ctx, 0, payload_ptr, message_length); -#elif defined(USE_ESP32) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) - // PSA Crypto multipart AEAD (no tag verification, matching legacy behavior) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, this->decryption_key_.size() * 8); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_DECRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_GCM); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); - mbedtls_svc_key_id_t key_id; - bool decrypt_failed = true; - if (psa_import_key(&attributes, this->decryption_key_.data(), this->decryption_key_.size(), &key_id) == PSA_SUCCESS) { - psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT; - if (psa_aead_decrypt_setup(&op, key_id, PSA_ALG_GCM) == PSA_SUCCESS && - psa_aead_set_nonce(&op, iv, sizeof(iv)) == PSA_SUCCESS) { - size_t outlen = 0; - if (psa_aead_update(&op, payload_ptr, message_length, payload_ptr, message_length, &outlen) == PSA_SUCCESS && - outlen == message_length) { - decrypt_failed = false; + this->bytes_accumulated_ = 0; +} + +void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { + int updated_count = 0; + +#ifdef USE_SENSOR + if (is_numeric) { + for (auto &item : this->sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(float_val); + updated_count++; } } - psa_aead_abort(&op); - psa_destroy_key(key_id); - } - if (decrypt_failed) { - ESP_LOGE(TAG, "Decryption failed"); - this->receive_buffer_.clear(); - return false; - } -#else - size_t outlen = 0; - mbedtls_gcm_context gcm_ctx; - mbedtls_gcm_init(&gcm_ctx); - mbedtls_gcm_setkey(&gcm_ctx, MBEDTLS_CIPHER_ID_AES, this->decryption_key_.data(), this->decryption_key_.size() * 8); - mbedtls_gcm_starts(&gcm_ctx, MBEDTLS_GCM_DECRYPT, iv, sizeof(iv)); - auto ret = mbedtls_gcm_update(&gcm_ctx, payload_ptr, message_length, payload_ptr, message_length, &outlen); - mbedtls_gcm_free(&gcm_ctx); - if (ret != 0) { - ESP_LOGE(TAG, "Decryption failed with error: %d", ret); - this->receive_buffer_.clear(); - return false; } #endif -#else -#error "Invalid Platform" + +#ifdef USE_TEXT_SENSOR + if (!is_numeric && str_val != nullptr) { + for (auto &item : this->text_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(str_val); + updated_count++; + } + } + } #endif - if (payload_ptr[0] != DATA_NOTIFICATION || payload_ptr[5] != TIMESTAMP_DATETIME) { - ESP_LOGE(TAG, "OBIS: Packet was decrypted but data is invalid"); - this->receive_buffer_.clear(); - return false; - } - ESP_LOGV(TAG, "Decrypted payload: %d bytes", message_length); - return true; -} - -void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_length) { - ESP_LOGV(TAG, "Decoding payload"); - MeterData data{}; - uint16_t current_position = DECODER_START_OFFSET; - bool power_factor_found = false; - - while (current_position + OBIS_CODE_OFFSET <= message_length) { - if (plaintext[current_position + OBIS_TYPE_OFFSET] != DataType::OCTET_STRING) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header type: %x", plaintext[current_position + OBIS_TYPE_OFFSET]); - this->receive_buffer_.clear(); - return; - } - - uint8_t obis_code_length = plaintext[current_position + OBIS_LENGTH_OFFSET]; - if (obis_code_length != OBIS_CODE_LENGTH_STANDARD && obis_code_length != OBIS_CODE_LENGTH_EXTENDED) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS header length: %x", obis_code_length); - this->receive_buffer_.clear(); - return; - } - if (current_position + OBIS_CODE_OFFSET + obis_code_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OBIS code"); - this->receive_buffer_.clear(); - return; - } - - uint8_t *obis_code = &plaintext[current_position + OBIS_CODE_OFFSET]; - uint8_t obis_medium = obis_code[OBIS_A]; - uint16_t obis_cd = encode_uint16(obis_code[OBIS_C], obis_code[OBIS_D]); - - bool timestamp_found = false; - bool meter_number_found = false; - if (this->provider_ == PROVIDER_NETZNOE) { - // Do not advance Position when reading the Timestamp at DECODER_START_OFFSET - if ((obis_code_length == OBIS_CODE_LENGTH_EXTENDED) && (current_position == DECODER_START_OFFSET)) { - timestamp_found = true; - } else if (power_factor_found) { - meter_number_found = true; - power_factor_found = false; - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code and position - } - } else { - current_position += obis_code_length + OBIS_CODE_OFFSET; // Advance past code, position and type - } - if (!timestamp_found && !meter_number_found && obis_medium != Medium::ELECTRICITY && - obis_medium != Medium::ABSTRACT) { - ESP_LOGE(TAG, "OBIS: Unsupported OBIS medium: %x", obis_medium); - this->receive_buffer_.clear(); - return; - } - - if (current_position >= message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for data type"); - this->receive_buffer_.clear(); - return; - } - - float value = 0.0f; - uint8_t value_size = 0; - uint8_t data_type = plaintext[current_position]; - current_position++; - - switch (data_type) { - case DataType::DOUBLE_LONG_UNSIGNED: { - value_size = 4; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for DOUBLE_LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint32(plaintext[current_position + 0], plaintext[current_position + 1], - plaintext[current_position + 2], plaintext[current_position + 3]); - current_position += value_size; - break; - } - case DataType::LONG_UNSIGNED: { - value_size = 2; - if (current_position + value_size > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for LONG_UNSIGNED"); - this->receive_buffer_.clear(); - return; - } - value = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - current_position += value_size; - break; - } - case DataType::OCTET_STRING: { - uint8_t data_length = plaintext[current_position]; - current_position++; // Advance past string length - if (current_position + data_length > message_length) { - ESP_LOGE(TAG, "OBIS: Buffer too short for OCTET_STRING"); - this->receive_buffer_.clear(); - return; - } - // Handle timestamp (normal OBIS code or NETZNOE special case) - if (obis_cd == OBIS_TIMESTAMP || timestamp_found) { - if (data_length < 8) { - ESP_LOGE(TAG, "OBIS: Timestamp data too short: %u", data_length); - this->receive_buffer_.clear(); - return; - } - uint16_t year = encode_uint16(plaintext[current_position + 0], plaintext[current_position + 1]); - uint8_t month = plaintext[current_position + 2]; - uint8_t day = plaintext[current_position + 3]; - uint8_t hour = plaintext[current_position + 5]; - uint8_t minute = plaintext[current_position + 6]; - uint8_t second = plaintext[current_position + 7]; - if (year > 9999 || month > 12 || day > 31 || hour > 23 || minute > 59 || second > 59) { - ESP_LOGE(TAG, "Invalid timestamp values: %04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, minute, - second); - this->receive_buffer_.clear(); - return; - } - snprintf(data.timestamp, sizeof(data.timestamp), "%04u-%02u-%02uT%02u:%02u:%02uZ", year, month, day, hour, - minute, second); - } else if (meter_number_found) { - snprintf(data.meternumber, sizeof(data.meternumber), "%.*s", data_length, &plaintext[current_position]); - } - current_position += data_length; - break; - } - default: - ESP_LOGE(TAG, "OBIS: Unsupported OBIS data type: %x", data_type); - this->receive_buffer_.clear(); - return; - } - - // Skip break after data - if (this->provider_ == PROVIDER_NETZNOE) { - // Don't skip the break on the first timestamp, as there's none - if (!timestamp_found) { - current_position += 2; - } - } else { - current_position += 2; - } - - // Check for additional data (scaler-unit structure) - if (current_position < message_length && plaintext[current_position] == DataType::INTEGER) { - // Apply scaler: real_value = raw_value × 10^scaler - if (current_position + 1 < message_length) { - int8_t scaler = static_cast(plaintext[current_position + 1]); - if (scaler != 0) { - value *= pow10_int(scaler); - } - } - - // on EVN Meters there is no additional break - if (this->provider_ == PROVIDER_NETZNOE) { - current_position += 4; - } else { - current_position += 6; - } - } - - // Handle numeric values (LONG_UNSIGNED and DOUBLE_LONG_UNSIGNED) - if (value_size > 0) { - switch (obis_cd) { - case OBIS_VOLTAGE_L1: - data.voltage_l1 = value; - break; - case OBIS_VOLTAGE_L2: - data.voltage_l2 = value; - break; - case OBIS_VOLTAGE_L3: - data.voltage_l3 = value; - break; - case OBIS_CURRENT_L1: - data.current_l1 = value; - break; - case OBIS_CURRENT_L2: - data.current_l2 = value; - break; - case OBIS_CURRENT_L3: - data.current_l3 = value; - break; - case OBIS_ACTIVE_POWER_PLUS: - data.active_power_plus = value; - break; - case OBIS_ACTIVE_POWER_MINUS: - data.active_power_minus = value; - break; - case OBIS_ACTIVE_ENERGY_PLUS: - data.active_energy_plus = value; - break; - case OBIS_ACTIVE_ENERGY_MINUS: - data.active_energy_minus = value; - break; - case OBIS_REACTIVE_ENERGY_PLUS: - data.reactive_energy_plus = value; - break; - case OBIS_REACTIVE_ENERGY_MINUS: - data.reactive_energy_minus = value; - break; - case OBIS_POWER_FACTOR: - data.power_factor = value; - power_factor_found = true; - break; - default: - ESP_LOGW(TAG, "Unsupported OBIS code 0x%04X", obis_cd); +#ifdef USE_BINARY_SENSOR + if (is_numeric) { + bool state = float_val != 0.0f; + for (auto &item : this->binary_sensors_) { + if (item.obis_code == obis_code) { + item.sensor->publish_state(state); + updated_count++; } } } +#endif - this->receive_buffer_.clear(); - - ESP_LOGI(TAG, "Received valid data"); - this->publish_sensors(data); - this->status_clear_warning(); + if (updated_count == 0) { + ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + } } +#ifdef USE_SENSOR +void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { + this->sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_TEXT_SENSOR +void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis_code, sensor}); +} +#endif +#ifdef USE_BINARY_SENSOR +void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis_code, sensor}); +} +#endif + } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index c50e6f6b4d..fc4721843f 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -2,95 +2,150 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif #ifdef USE_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif -#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif -#include "mbus.h" -#include "dlms.h" -#include "obis.h" +#include -#include #include +#include +#include +#include +#include + +#if __has_include() +#include +#elif !defined(USE_ESP8266) && __has_include() +#if __has_include() +#include +#endif +#include +#elif __has_include() +#include +#else +#define DLMS_METER_NO_CRYPTO +#endif + +#ifndef DLMS_MAX_SENSORS +static constexpr uint8_t DLMS_MAX_SENSORS = 0; +#endif +#ifndef DLMS_MAX_TEXT_SENSORS +static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; +#endif +#ifndef DLMS_MAX_BINARY_SENSORS +static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; +#endif namespace esphome::dlms_meter { -#ifndef DLMS_METER_SENSOR_LIST -#define DLMS_METER_SENSOR_LIST(F, SEP) +#ifdef DLMS_METER_NO_CRYPTO +// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) +class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { + public: + void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} + bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, + std::span aad, std::span tag) override { + return false; + } +}; #endif -#ifndef DLMS_METER_TEXT_SENSOR_LIST -#define DLMS_METER_TEXT_SENSOR_LIST(F, SEP) +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#elif __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#else +using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; #endif -struct MeterData { - float voltage_l1 = 0.0f; // Voltage L1 - float voltage_l2 = 0.0f; // Voltage L2 - float voltage_l3 = 0.0f; // Voltage L3 - float current_l1 = 0.0f; // Current L1 - float current_l2 = 0.0f; // Current L2 - float current_l3 = 0.0f; // Current L3 - float active_power_plus = 0.0f; // Active power taken from grid - float active_power_minus = 0.0f; // Active power put into grid - float active_energy_plus = 0.0f; // Active energy taken from grid - float active_energy_minus = 0.0f; // Active energy put into grid - float reactive_energy_plus = 0.0f; // Reactive energy taken from grid - float reactive_energy_minus = 0.0f; // Reactive energy put into grid - char timestamp[27]{}; // Text sensor for the timestamp value +#ifdef USE_SENSOR +struct SensorItem { + std::string obis_code; + sensor::Sensor *sensor; +}; +#endif +#ifdef USE_TEXT_SENSOR +struct TextSensorItem { + std::string obis_code; + text_sensor::TextSensor *sensor; +}; +#endif +#ifdef USE_BINARY_SENSOR +struct BinarySensorItem { + std::string obis_code; + binary_sensor::BinarySensor *sensor; +}; +#endif - // Netz NOE - float power_factor = 0.0f; // Power Factor - char meternumber[13]{}; // Text sensor for the meterNumber value +struct CustomPattern { + std::string pattern; + std::optional name; + int priority{0}; + std::optional> default_obis; }; -// Provider constants -enum Providers : uint32_t { PROVIDER_GENERIC = 0x00, PROVIDER_NETZNOE = 0x01 }; - -class DlmsMeterComponent : public Component, public uart::UARTDevice { +class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: - DlmsMeterComponent() = default; + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, + std::optional> decryption_key, + std::optional> authentication_key, + std::vector custom_patterns); + void setup() override; void dump_config() override; void loop() override; - void set_decryption_key(const std::array &key) { this->decryption_key_ = key; } - void set_provider(uint32_t provider) { this->provider_ = provider; } - - void publish_sensors(MeterData &data) { -#define DLMS_METER_PUBLISH_SENSOR(s) \ - if (this->s##_sensor_ != nullptr) \ - s##_sensor_->publish_state(data.s); - DLMS_METER_SENSOR_LIST(DLMS_METER_PUBLISH_SENSOR, ) - -#define DLMS_METER_PUBLISH_TEXT_SENSOR(s) \ - if (this->s##_text_sensor_ != nullptr) \ - s##_text_sensor_->publish_state(data.s); - DLMS_METER_TEXT_SENSOR_LIST(DLMS_METER_PUBLISH_TEXT_SENSOR, ) - } - - DLMS_METER_SENSOR_LIST(SUB_SENSOR, ) - DLMS_METER_TEXT_SENSOR_LIST(SUB_TEXT_SENSOR, ) +#ifdef USE_SENSOR + void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); +#endif +#ifdef USE_TEXT_SENSOR + void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); +#endif +#ifdef USE_BINARY_SENSOR + void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); +#endif protected: - bool parse_mbus_(std::vector &mbus_payload); - bool parse_dlms_(const std::vector &mbus_payload, uint16_t &message_length, uint8_t &systitle_length, - uint16_t &header_offset); - bool decrypt_(std::vector &mbus_payload, uint16_t message_length, uint8_t systitle_length, - uint16_t header_offset); - void decode_obis_(uint8_t *plaintext, uint16_t message_length); + void read_rx_buffer_(); + void flush_rx_buffer_(); + void process_frame_(); + void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); - std::vector receive_buffer_; // Stores the packet currently being received - std::vector mbus_payload_; // Parsed M-Bus payload, reused to avoid heap churn - uint32_t last_read_ = 0; // Timestamp when data was last read - uint32_t read_timeout_ = 1000; // Time to wait after last byte before considering data complete + std::array rx_buffer_; + size_t bytes_accumulated_{0}; + uint32_t last_rx_char_time_{0}; - uint32_t provider_ = PROVIDER_GENERIC; // Provider of the meter / your grid operator - std::array decryption_key_; + uint32_t receive_timeout_ms_{1000}; + bool skip_crc_check_{false}; + + std::vector custom_patterns_; + + Aes128GcmDecryptorImpl decryptor_; + dlms_parser::DlmsParser parser_; + +#ifdef USE_SENSOR + StaticVector sensors_; +#endif +#ifdef USE_TEXT_SENSOR + StaticVector text_sensors_; +#endif +#ifdef USE_BINARY_SENSOR + StaticVector binary_sensors_; +#endif }; } // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/mbus.h b/esphome/components/dlms_meter/mbus.h deleted file mode 100644 index 293d43a55b..0000000000 --- a/esphome/components/dlms_meter/mbus.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -/* -+----------------------------------------------------+ - -| Start Character [0x68] | \ -+----------------------------------------------------+ | -| Data Length (L) | | -+----------------------------------------------------+ | -| Data Length Repeat (L) | | -+----------------------------------------------------+ > M-Bus Data link layer -| Start Character Repeat [0x68] | | -+----------------------------------------------------+ | -| Control/Function Field (C) | | -+----------------------------------------------------+ | -| Address Field (A) | / -+----------------------------------------------------+ - -| Control Information Field (CI) | \ -+----------------------------------------------------+ | -| Source Transport Service Access Point (STSAP) | > DLMS/COSEM M-Bus transport layer -+----------------------------------------------------+ | -| Destination Transport Service Access Point (DTSAP) | / -+----------------------------------------------------+ - -| | \ -~ ~ | - Data > DLMS/COSEM Application Layer -~ ~ | -| | / -+----------------------------------------------------+ - -| Checksum | \ -+----------------------------------------------------+ > M-Bus Data link layer -| Stop Character [0x16] | / -+----------------------------------------------------+ - - -Data_Length = L - C - A - CI -Each line (except Data) is one Byte - -Possible Values found in publicly available docs: -- C: 0x53/0x73 (SND_UD) -- A: FF (Broadcast) -- CI: 0x00-0x1F/0x60/0x61/0x7C/0x7D -- STSAP: 0x01 (Management Logical Device ID 1 of the meter) -- DTSAP: 0x67 (Consumer Information Push Client ID 103) - */ - -// MBUS start bytes for different telegram formats: -// - Single Character: 0xE5 (length=1) -// - Short Frame: 0x10 (length=5) -// - Control Frame: 0x68 (length=9) -// - Long Frame: 0x68 (length=9+data_length) -// This component currently only uses Long Frame. -static constexpr uint8_t START_BYTE_SINGLE_CHARACTER = 0xE5; -static constexpr uint8_t START_BYTE_SHORT_FRAME = 0x10; -static constexpr uint8_t START_BYTE_CONTROL_FRAME = 0x68; -static constexpr uint8_t START_BYTE_LONG_FRAME = 0x68; -static constexpr uint8_t MBUS_HEADER_INTRO_LENGTH = 4; // Header length for the intro (0x68, length, length, 0x68) -static constexpr uint8_t MBUS_FULL_HEADER_LENGTH = 9; // Total header length -static constexpr uint8_t MBUS_FOOTER_LENGTH = 2; // Footer after frame -static constexpr uint8_t MBUS_MAX_FRAME_LENGTH = 250; // Maximum size of frame -static constexpr uint8_t MBUS_START1_OFFSET = 0; // Offset of first start byte -static constexpr uint8_t MBUS_LENGTH1_OFFSET = 1; // Offset of first length byte -static constexpr uint8_t MBUS_LENGTH2_OFFSET = 2; // Offset of (duplicated) second length byte -static constexpr uint8_t MBUS_START2_OFFSET = 3; // Offset of (duplicated) second start byte -static constexpr uint8_t STOP_BYTE = 0x16; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/obis.h b/esphome/components/dlms_meter/obis.h deleted file mode 100644 index 1bb960e61e..0000000000 --- a/esphome/components/dlms_meter/obis.h +++ /dev/null @@ -1,94 +0,0 @@ -#pragma once - -#include - -namespace esphome::dlms_meter { - -// Data types as per specification -enum DataType { - NULL_DATA = 0x00, - BOOLEAN = 0x03, - BIT_STRING = 0x04, - DOUBLE_LONG = 0x05, - DOUBLE_LONG_UNSIGNED = 0x06, - OCTET_STRING = 0x09, - VISIBLE_STRING = 0x0A, - UTF8_STRING = 0x0C, - BINARY_CODED_DECIMAL = 0x0D, - INTEGER = 0x0F, - LONG = 0x10, - UNSIGNED = 0x11, - LONG_UNSIGNED = 0x12, - LONG64 = 0x14, - LONG64_UNSIGNED = 0x15, - ENUM = 0x16, - FLOAT32 = 0x17, - FLOAT64 = 0x18, - DATE_TIME = 0x19, - DATE = 0x1A, - TIME = 0x1B, - - ARRAY = 0x01, - STRUCTURE = 0x02, - COMPACT_ARRAY = 0x13 -}; - -enum Medium { - ABSTRACT = 0x00, - ELECTRICITY = 0x01, - HEAT_COST_ALLOCATOR = 0x04, - COOLING = 0x05, - HEAT = 0x06, - GAS = 0x07, - COLD_WATER = 0x08, - HOT_WATER = 0x09, - OIL = 0x10, - COMPRESSED_AIR = 0x11, - NITROGEN = 0x12 -}; - -// Data structure -static constexpr uint8_t DECODER_START_OFFSET = 20; // Skip header, timestamp and break block -static constexpr uint8_t OBIS_TYPE_OFFSET = 0; -static constexpr uint8_t OBIS_LENGTH_OFFSET = 1; -static constexpr uint8_t OBIS_CODE_OFFSET = 2; -static constexpr uint8_t OBIS_CODE_LENGTH_STANDARD = 0x06; // 6-byte OBIS code (A.B.C.D.E.F) -static constexpr uint8_t OBIS_CODE_LENGTH_EXTENDED = 0x0C; // 12-byte extended OBIS code -static constexpr uint8_t OBIS_A = 0; -static constexpr uint8_t OBIS_B = 1; -static constexpr uint8_t OBIS_C = 2; -static constexpr uint8_t OBIS_D = 3; -static constexpr uint8_t OBIS_E = 4; -static constexpr uint8_t OBIS_F = 5; - -// Metadata -static constexpr uint16_t OBIS_TIMESTAMP = 0x0100; -static constexpr uint16_t OBIS_SERIAL_NUMBER = 0x6001; -static constexpr uint16_t OBIS_DEVICE_NAME = 0x2A00; - -// Voltage -static constexpr uint16_t OBIS_VOLTAGE_L1 = 0x2007; -static constexpr uint16_t OBIS_VOLTAGE_L2 = 0x3407; -static constexpr uint16_t OBIS_VOLTAGE_L3 = 0x4807; - -// Current -static constexpr uint16_t OBIS_CURRENT_L1 = 0x1F07; -static constexpr uint16_t OBIS_CURRENT_L2 = 0x3307; -static constexpr uint16_t OBIS_CURRENT_L3 = 0x4707; - -// Power -static constexpr uint16_t OBIS_ACTIVE_POWER_PLUS = 0x0107; -static constexpr uint16_t OBIS_ACTIVE_POWER_MINUS = 0x0207; - -// Active energy -static constexpr uint16_t OBIS_ACTIVE_ENERGY_PLUS = 0x0108; -static constexpr uint16_t OBIS_ACTIVE_ENERGY_MINUS = 0x0208; - -// Reactive energy -static constexpr uint16_t OBIS_REACTIVE_ENERGY_PLUS = 0x0308; -static constexpr uint16_t OBIS_REACTIVE_ENERGY_MINUS = 0x0408; - -// Netz NOE specific -static constexpr uint16_t OBIS_POWER_FACTOR = 0x0D07; - -} // namespace esphome::dlms_meter diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 27fd44f008..8ded150cd0 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,8 +1,9 @@ +import logging + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import ( - CONF_ID, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, @@ -15,110 +16,144 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +NUMERIC_KEYS = { + "voltage_l1": "1.0.32.7.0.255", + "voltage_l2": "1.0.52.7.0.255", + "voltage_l3": "1.0.72.7.0.255", + "current_l1": "1.0.31.7.0.255", + "current_l2": "1.0.51.7.0.255", + "current_l3": "1.0.71.7.0.255", + "active_power_plus": "1.0.1.7.0.255", + "active_power_minus": "1.0.2.7.0.255", + "active_energy_plus": "1.0.1.8.0.255", + "active_energy_minus": "1.0.2.8.0.255", + "reactive_energy_plus": "1.0.3.8.0.255", + "reactive_energy_minus": "1.0.4.8.0.255", + "power_factor": "1.0.13.7.0.255", +} + +DYNAMIC_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - # Netz NOE - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) -async def to_code(config): +def deprecation_warning(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("voltage_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("voltage_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l1"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l2"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("current_l3"): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_power_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("active_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("active_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_plus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_minus"): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("power_factor"): sensor.sensor_schema( + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) + + +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == sensor.Sensor: - sens = await sensor.new_sensor(conf) - cg.add(getattr(hub, f"set_{key}_sensor")(sens)) - sensors.append(f"F({key})") - - if sensors: - cg.add_define( - "DLMS_METER_SENSOR_LIST(F, sep)", cg.RawExpression(" sep ".join(sensors)) - ) + if obis := config.get(CONF_OBIS_CODE): + var = await sensor.new_sensor(config) + cg.add(hub.register_sensor(obis, var)) + else: + for key, obis_val in NUMERIC_KEYS.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(hub.register_sensor(obis_val, sens)) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 4d2373f4f9..c2ff0779ee 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,37 +1,60 @@ +import logging + import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, DlmsMeterComponent +from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code -AUTO_LOAD = ["dlms_meter"] +_LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.Schema( +DEPENDENCIES = ["dlms_meter"] + +TEXT_KEYS = { + "timestamp": "0.0.1.0.0.255", + "meternumber": "0.0.96.1.0.255", +} + +DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - # Netz NOE - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + cv.Required(CONF_OBIS_CODE): obis_code, } -).extend(cv.COMPONENT_SCHEMA) +) -async def to_code(config): +def deprecation_warning(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " + "Please update your configuration to use the new schema with 'obis_code'." + ) + return config + + +OLD_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), + cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("meternumber"): text_sensor.text_sensor_schema(), + } + ).extend(cv.COMPONENT_SCHEMA), + deprecation_warning, +) + + +CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) + + +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - text_sensors = [] - for key, conf in config.items(): - if not isinstance(conf, dict): - continue - id = conf[CONF_ID] - if id and id.type == text_sensor.TextSensor: - sens = await text_sensor.new_text_sensor(conf) - cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) - text_sensors.append(f"F({key})") - - if text_sensors: - cg.add_define( - "DLMS_METER_TEXT_SENSOR_LIST(F, sep)", - cg.RawExpression(" sep ".join(text_sensors)), - ) + if obis := config.get(CONF_OBIS_CODE): + var = await text_sensor.new_text_sensor(config) + cg.add(hub.register_text_sensor(obis, var)) + else: + for key, obis_val in TEXT_KEYS.items(): + if text_sensor_config := config.get(key): + sens = await text_sensor.new_text_sensor(text_sensor_config) + cg.add(hub.register_text_sensor(obis_val, sens)) 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/dps310/sensor.py b/esphome/components/dps310/sensor.py index 605812beaa..8b8fd8373b 100644 --- a/esphome/components/dps310/sensor.py +++ b/esphome/components/dps310/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_HECTOPASCAL, ) +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -48,7 +49,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/ds1307/time.py b/esphome/components/ds1307/time.py index 0e7bb976a2..a3ae3eb5af 100644 --- a/esphome/components/ds1307/time.py +++ b/esphome/components/ds1307/time.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import i2c, time import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@badbadc0ffee"] DEPENDENCIES = ["i2c"] @@ -29,7 +32,12 @@ CONFIG_SCHEMA = time.TIME_SCHEMA.extend( ), synchronous=True, ) -async def ds1307_write_time_to_code(config, action_id, template_arg, args): +async def ds1307_write_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -45,13 +53,18 @@ async def ds1307_write_time_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def ds1307_read_time_to_code(config, action_id, template_arg, args): +async def ds1307_read_time_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/ds2484/one_wire.py b/esphome/components/ds2484/one_wire.py index 384b2d01e6..f6277cd68e 100644 --- a/esphome/components/ds2484/one_wire.py +++ b/esphome/components/ds2484/one_wire.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType ds2484_ns = cg.esphome_ns.namespace("ds2484") @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await i2c.register_i2c_device(var, config) await cg.register_component(var, config) diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py new file mode 100644 index 0000000000..a2e2a87ed0 --- /dev/null +++ b/esphome/components/ds248x/__init__.py @@ -0,0 +1,113 @@ +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_SLEEP_PIN, CONF_TYPE +from esphome.types import ConfigType + +CODEOWNERS = ["@tomwellnitz"] +MULTI_CONF = True +DEPENDENCIES = ["i2c"] + +CONF_DS248X_ID = "ds248x_id" +CONF_BUS_SLEEP = "bus_sleep" +CONF_HUB_SLEEP = "hub_sleep" +CONF_ACTIVE_PULLUP = "active_pullup" + +CONF_RESET_LOW_TIME = "reset_low_time" +CONF_MASTER_SAMPLE_TIME = "master_sample_time" +CONF_WRITE_0_LOW_TIME = "write_0_low_time" +CONF_RECOVERY_TIME = "recovery_time" +CONF_ACTIVE_PULLUP_RESISTANCE = "active_pullup_resistance" + +TYPE_DS2482_100 = "ds2482-100" +TYPE_DS2482_101 = "ds2482-101" +TYPE_DS2482_800 = "ds2482-800" +TYPE_DS2484 = "ds2484" + +CHANNEL_COUNTS = { + TYPE_DS2482_100: 1, + TYPE_DS2482_101: 1, + TYPE_DS2482_800: 8, + TYPE_DS2484: 1, +} + +ds248x_ns = cg.esphome_ns.namespace("ds248x") +DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) + + +def _component_schema(*extras: dict) -> cv.Schema: + schema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xComponent), + cv.Optional(CONF_ACTIVE_PULLUP, default=False): cv.boolean, + } + ) + for extra in extras: + schema = schema.extend(extra) + return schema.extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x18)) + + +SLEEP_SCHEMA = { + cv.Optional(CONF_SLEEP_PIN): pins.internal_gpio_output_pin_schema, + cv.Optional(CONF_BUS_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_HUB_SLEEP, default=False): cv.boolean, +} + +DS2484_SCHEMA = { + cv.Optional(CONF_RESET_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_MASTER_SAMPLE_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_WRITE_0_LOW_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_RECOVERY_TIME): cv.int_range(min=0, max=15), + cv.Optional(CONF_ACTIVE_PULLUP_RESISTANCE): cv.enum( + { + # DS2484 Table 7: value codes 0-5 map to 500 ohm, 6-15 map to 1000 ohm. + "500ohm": 0, + "1000ohm": 6, + } + ), +} + +CONFIG_SCHEMA = cv.typed_schema( + { + TYPE_DS2482_100: _component_schema(), + TYPE_DS2482_101: _component_schema(SLEEP_SCHEMA), + TYPE_DS2482_800: _component_schema(), + TYPE_DS2484: _component_schema(SLEEP_SCHEMA, DS2484_SCHEMA), + }, + key=CONF_TYPE, + lower=True, +) + + +def get_channel_count(config: ConfigType) -> int: + return CHANNEL_COUNTS[config[CONF_TYPE]] + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP])) + cg.add(var.set_channel_count(get_channel_count(config))) + + if CONF_BUS_SLEEP in config: + cg.add(var.set_bus_sleep(config[CONF_BUS_SLEEP])) + if CONF_HUB_SLEEP in config: + cg.add(var.set_hub_sleep(config[CONF_HUB_SLEEP])) + + if CONF_RESET_LOW_TIME in config: + cg.add(var.set_val_trstl(config[CONF_RESET_LOW_TIME])) + if CONF_MASTER_SAMPLE_TIME in config: + cg.add(var.set_val_tmsp(config[CONF_MASTER_SAMPLE_TIME])) + if CONF_WRITE_0_LOW_TIME in config: + cg.add(var.set_val_tw0l(config[CONF_WRITE_0_LOW_TIME])) + if CONF_RECOVERY_TIME in config: + cg.add(var.set_val_trec0(config[CONF_RECOVERY_TIME])) + if CONF_ACTIVE_PULLUP_RESISTANCE in config: + cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE])) + + if CONF_SLEEP_PIN in config: + pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN]) + cg.add(var.set_sleep_pin(pin)) diff --git a/esphome/components/ds248x/ds248x.cpp b/esphome/components/ds248x/ds248x.cpp new file mode 100644 index 0000000000..c8f7395119 --- /dev/null +++ b/esphome/components/ds248x/ds248x.cpp @@ -0,0 +1,320 @@ +#include "ds248x.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x"; + +void DS248xComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x..."); + + // Wake up device if sleep pin is configured + if (this->sleep_pin_) { + this->sleep_pin_->setup(); + this->sleep_pin_->pin_mode(esphome::gpio::FLAG_OUTPUT); + this->sleep_pin_->digital_write(true); // Wake up + delay(1); // DS2482-101 Datasheet: tOSCWUP = 100μs (using 10x margin) + } + + // Probe device + ESP_LOGD(TAG, "Probing DS248x..."); + uint8_t status = 0; + if (this->read(&status, 1) == i2c::ERROR_OK) { + ESP_LOGD(TAG, "Device responded! Status: 0x%02x", status); + } else { + ESP_LOGW(TAG, "Device did not respond. Trying reset anyway..."); + } + + if (!this->device_reset_()) { + ESP_LOGW(TAG, "DS248x reset failed during setup!"); + } + + // Configure device + if (!this->device_configure_()) { + ESP_LOGE(TAG, "DS248x configuration failed!"); + this->mark_failed(); + return; + } + + // Reset to Channel 0 + this->select_channel(0); + + ESP_LOGI(TAG, "DS248x initialized successfully."); +} + +void DS248xComponent::on_shutdown() { + if (this->sleep_pin_ && (this->hub_sleep_ || this->bus_sleep_)) { + this->sleep_pin_->digital_write(false); // Sleep + } +} + +void DS248xComponent::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x:"); + LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, " Channel Count: %d", this->channel_count_); + ESP_LOGCONFIG(TAG, " Active Pullup: %s", YESNO(this->active_pullup_)); + if (this->ds2484_mode_) { + ESP_LOGCONFIG(TAG, " DS2484 Mode: enabled"); + } +} + +// --- Internal Helpers --- + +// Datasheet command durations are sub-2ms; allow a little margin before forcing recovery. +static constexpr uint32_t BUSY_TIMEOUT_MS = 5; + +bool DS248xComponent::set_read_pointer_(uint8_t ptr) { return this->write_byte(DS248X_COMMAND_SETREADPTR, ptr); } + +bool DS248xComponent::wait_busy_() { + uint32_t start = millis(); + do { + uint8_t status; + if (this->read(&status, 1) == i2c::ERROR_OK && !(status & DS248X_STATUS_BUSY)) + return true; + delayMicroseconds(100); + } while (millis() - start < BUSY_TIMEOUT_MS); + ESP_LOGW(TAG, "DS248x busy timeout"); + bool recovered = this->device_reset_() && this->device_configure_(); + this->current_channel_ = -1; + if (!recovered) { + ESP_LOGE(TAG, "DS248x recovery failed after busy timeout"); + this->mark_failed(); + } + return false; +} + +bool DS248xComponent::device_reset_() { + ESP_LOGD(TAG, "Resetting device..."); + uint8_t cmd = DS248X_COMMAND_RESET; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + if (!(status & DS248X_STATUS_RST)) { + ESP_LOGW(TAG, "Device reset failed (RST bit not set)"); + return false; + } + + this->current_channel_ = -1; + return true; +} + +bool DS248xComponent::device_configure_() { + ESP_LOGD(TAG, "Configuring device..."); + + if (!this->write_config_()) { + ESP_LOGW(TAG, "Config write/verify failed"); + return false; + } + + ESP_LOGD(TAG, "Configured successfully"); + + // DS2484 Configuration + if (this->ds2484_mode_) { + if (this->ds2484_trstl_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TRSTL, this->ds2484_trstl_)) + return false; + if (this->ds2484_tmsp_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TMSP, this->ds2484_tmsp_)) + return false; + if (this->ds2484_tw0l_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TW0L, this->ds2484_tw0l_)) + return false; + if (this->ds2484_trec0_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_TREC0, this->ds2484_trec0_)) + return false; + if (this->ds2484_rwpu_ != DS2484_PARAM_UNSET && + !this->configure_ds2484_port_(DS2484_PORT_PARAM_RWPU, this->ds2484_rwpu_)) + return false; + } + + return true; +} + +bool DS248xComponent::configure_ds2484_port_(uint8_t param, uint8_t val) { + uint8_t cmd = DS2484_COMMAND_ADJUSTPORT; + // Control Byte format (DS2484 Table 6): P[2:0] in bits 7:5, OD in bit 4, VAL[3:0] in bits 3:0 + uint8_t data = ((param & 0x07) << 5) | (val & 0x0F); + + // The DS2484 always acknowledges the Adjust 1-Wire Port control byte (datasheet "Adjust + // 1-Wire Port"), so a successful write confirms the update. We deliberately do not read + // back to verify: a single read of the Port Configuration register always returns the + // fixed 8-byte report starting at Byte 1 (tRSTL standard speed), not the parameter that + // was just written, so a per-parameter readback comparison would spuriously fail for + // tMSP/tW0L/tREC0/RWPU. + if (!this->write_byte(cmd, data)) { + ESP_LOGW(TAG, "DS2484 port config failed (param %d)", param); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +bool DS248xComponent::write_config_() { + uint8_t config = 0; + if (this->active_pullup_) + config |= DS248X_CONFIG_ACTIVE_PULLUP; + + // The DS248x only accepts the config byte if the upper nibble is the one's-complement of the lower nibble. + uint8_t config_byte = (config & 0x0F) | ((~config & 0x0F) << 4); + + if (!this->write_byte(DS248X_COMMAND_WRITECONFIG, config_byte)) { + ESP_LOGW(TAG, "Failed to write config byte"); + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_CONFIG)) { + return false; + } + + uint8_t read_config; + if (this->read(&read_config, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Failed to read back config byte"); + return false; + } + + if ((read_config & 0x0F) != (config_byte & 0x0F)) { + ESP_LOGW(TAG, "Config mismatch! Wrote 0x%02x, Read 0x%02x", config_byte, read_config); + return false; + } + + return this->set_read_pointer_(DS248X_POINTER_STATUS); +} + +// --- Channel Selection --- + +// Channel select codes: write code -> expected read code +static constexpr uint8_t CHANNEL_WRITE_CODES[8] = {0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87}; +static constexpr uint8_t CHANNEL_READ_CODES[8] = {0xB8, 0xB1, 0xAA, 0xA3, 0x9C, 0x95, 0x8E, 0x87}; + +bool DS248xComponent::select_channel(uint8_t channel) { + if (this->channel_count_ <= 1) + return true; + if (channel >= this->channel_count_) + return false; + + if (this->current_channel_ == channel) + return true; + + if (!this->write_byte(DS248X_COMMAND_CHANNELSELECT, CHANNEL_WRITE_CODES[channel])) { + this->current_channel_ = -1; + return false; + } + + uint8_t read_code; + if (this->read(&read_code, 1) != i2c::ERROR_OK) { + this->current_channel_ = -1; + return false; + } + + if (read_code != CHANNEL_READ_CODES[channel]) { + ESP_LOGW(TAG, "Channel select failed! Expected 0x%02x, got 0x%02x", CHANNEL_READ_CODES[channel], read_code); + this->current_channel_ = -1; + return false; + } + + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + this->current_channel_ = channel; + return true; +} + +// --- 1-Wire Bus Operations --- + +bool DS248xComponent::ow_reset(bool &presence) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_RESETWIRE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "ow_reset: wait busy failed"); + return false; + } + + uint8_t status; + if (this->read(&status, 1) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "ow_reset: read status failed"); + return false; + } + + if (status & DS248X_STATUS_SD) { + ESP_LOGW(TAG, "Short detected on 1-Wire bus!"); + return false; + } + + presence = (status & DS248X_STATUS_PPD); + return true; +} + +bool DS248xComponent::ow_write_byte(uint8_t byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Device busy before writing byte 0x%02x", byte); + return false; + } + + uint8_t cmd[2] = {DS248X_COMMAND_WRITEBYTE, byte}; + if (this->write(cmd, 2) != i2c::ERROR_OK) { + ESP_LOGW(TAG, "I2C write failed for byte 0x%02x", byte); + return false; + } + + if (!this->wait_busy_()) { + ESP_LOGW(TAG, "Timeout waiting for write byte to complete!"); + return false; + } + + return true; +} + +bool DS248xComponent::ow_read_byte(uint8_t &byte) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + uint8_t cmd = DS248X_COMMAND_READBYTE; + if (this->write(&cmd, 1) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (!this->set_read_pointer_(DS248X_POINTER_DATA)) + return false; + + if (this->read(&byte, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +bool DS248xComponent::search_triplet(bool search_direction, uint8_t &status) { + if (!this->set_read_pointer_(DS248X_POINTER_STATUS)) + return false; + + // DS248x Datasheet: 1-Wire Triplet command requires 2 bytes: + // Byte 1: Command code 0x78 + // Byte 2: Direction byte (bit 7 = V, search direction if discrepancy) + uint8_t buffer[2] = {DS248X_COMMAND_TRIPLET, static_cast(search_direction ? 0x80 : 0x00)}; + if (this->write(buffer, 2) != i2c::ERROR_OK) + return false; + + if (!this->wait_busy_()) + return false; + + if (this->read(&status, 1) != i2c::ERROR_OK) + return false; + + return true; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x.h b/esphome/components/ds248x/ds248x.h new file mode 100644 index 0000000000..0873ee0e2a --- /dev/null +++ b/esphome/components/ds248x/ds248x.h @@ -0,0 +1,133 @@ +#pragma once + +// DS248x I2C-to-1-Wire Bridge Family +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-100.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-800.pdf +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2484.pdf + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds248x { + +// DS248x I2C Commands +static constexpr uint8_t DS248X_COMMAND_RESET = 0xF0; +static constexpr uint8_t DS248X_COMMAND_SETREADPTR = 0xE1; +static constexpr uint8_t DS248X_COMMAND_WRITECONFIG = 0xD2; +static constexpr uint8_t DS248X_COMMAND_CHANNELSELECT = 0xC3; +static constexpr uint8_t DS248X_COMMAND_RESETWIRE = 0xB4; +static constexpr uint8_t DS248X_COMMAND_WRITEBYTE = 0xA5; +static constexpr uint8_t DS248X_COMMAND_READBYTE = 0x96; +static constexpr uint8_t DS248X_COMMAND_TRIPLET = 0x78; +static constexpr uint8_t DS2484_COMMAND_ADJUSTPORT = 0xC3; + +// DS2484 "Adjust 1-Wire Port" parameter codes (datasheet Table 6, control byte P[2:0]) +static constexpr uint8_t DS2484_PORT_PARAM_TRSTL = 0x0; +static constexpr uint8_t DS2484_PORT_PARAM_TMSP = 0x1; +static constexpr uint8_t DS2484_PORT_PARAM_TW0L = 0x2; +static constexpr uint8_t DS2484_PORT_PARAM_TREC0 = 0x3; +static constexpr uint8_t DS2484_PORT_PARAM_RWPU = 0x4; + +// DS248x Status Register Bits +static constexpr uint8_t DS248X_STATUS_BUSY = 0x01; +static constexpr uint8_t DS248X_STATUS_PPD = 0x02; +static constexpr uint8_t DS248X_STATUS_SD = 0x04; +static constexpr uint8_t DS248X_STATUS_RST = 0x10; +static constexpr uint8_t DS248X_STATUS_SBR = 0x20; +static constexpr uint8_t DS248X_STATUS_TSB = 0x40; +static constexpr uint8_t DS248X_STATUS_DIR = 0x80; + +// DS248x Register Pointers +static constexpr uint8_t DS248X_POINTER_STATUS = 0xF0; +static constexpr uint8_t DS248X_POINTER_DATA = 0xE1; +static constexpr uint8_t DS248X_POINTER_CONFIG = 0xC3; + +// DS248x Configuration Bits +static constexpr uint8_t DS248X_CONFIG_ACTIVE_PULLUP = 0x01; + +/** + * @brief DS248x I2C-to-1-Wire Bridge Component. + * + * This component manages the DS248x chip (DS2482-100, DS2482-800, DS2484). + * It provides low-level 1-Wire bus operations via I2C. + * + * Usage: Configure DS248xOneWireBus instances for each channel. + * These buses implement the one_wire::OneWireBus interface for compatibility + * with all existing 1-Wire device components (dallas_temp, etc.). + */ +class DS248xComponent : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + void set_sleep_pin(InternalGPIOPin *pin) { this->sleep_pin_ = pin; } + void set_bus_sleep(bool enabled) { this->bus_sleep_ = enabled; } + void set_hub_sleep(bool enabled) { this->hub_sleep_ = enabled; } + void set_channel_count(uint8_t count) { this->channel_count_ = count; } + void set_active_pullup(bool enabled) { this->active_pullup_ = enabled; } + + // DS2484 Timing Parameters + void set_val_trstl(uint8_t val) { + this->ds2484_trstl_ = val; + this->ds2484_mode_ = true; + } + void set_val_tmsp(uint8_t val) { + this->ds2484_tmsp_ = val; + this->ds2484_mode_ = true; + } + void set_val_tw0l(uint8_t val) { + this->ds2484_tw0l_ = val; + this->ds2484_mode_ = true; + } + void set_val_trec0(uint8_t val) { + this->ds2484_trec0_ = val; + this->ds2484_mode_ = true; + } + void set_val_rwpu(uint8_t val) { + this->ds2484_rwpu_ = val; + this->ds2484_mode_ = true; + } + + /// Get the channel count (1 for DS2482-100/DS2484, 8 for DS2482-800) + uint8_t get_channel_count() const { return this->channel_count_; } + + // --- Core 1-Wire API (used by DS248xOneWireBus) --- + bool select_channel(uint8_t channel); + bool ow_reset(bool &presence); + bool ow_write_byte(uint8_t byte); + bool ow_read_byte(uint8_t &byte); + + // --- Search support (used by DS248xOneWireBus) --- + bool search_triplet(bool search_direction, uint8_t &status); + + protected: + InternalGPIOPin *sleep_pin_{nullptr}; + uint8_t channel_count_ = 1; + bool bus_sleep_{false}; + bool hub_sleep_{false}; + bool active_pullup_ = false; + + // DS2484 Config + bool ds2484_mode_ = false; + static constexpr uint8_t DS2484_PARAM_UNSET = 0xFF; + uint8_t ds2484_trstl_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tmsp_{DS2484_PARAM_UNSET}; + uint8_t ds2484_tw0l_{DS2484_PARAM_UNSET}; + uint8_t ds2484_trec0_{DS2484_PARAM_UNSET}; + uint8_t ds2484_rwpu_{DS2484_PARAM_UNSET}; + + int8_t current_channel_{-1}; + + // Internal helpers + bool set_read_pointer_(uint8_t ptr); + bool wait_busy_(); + bool device_reset_(); + bool device_configure_(); + bool configure_ds2484_port_(uint8_t param, uint8_t val); + bool write_config_(); +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.cpp b/esphome/components/ds248x/ds248x_one_wire_bus.cpp new file mode 100644 index 0000000000..ed5b05bab7 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.cpp @@ -0,0 +1,171 @@ +#include "ds248x_one_wire_bus.h" +#include "ds248x.h" +#include "esphome/core/log.h" + +namespace esphome::ds248x { + +static const char *const TAG = "ds248x.one_wire"; + +void DS248xOneWireBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_); + + // Parent setup happens in DS248xComponent::setup() + // We just need to scan for devices on this channel + if (!this->ensure_channel_()) { + ESP_LOGE(TAG, "Failed to select channel %d during setup", this->channel_); + this->mark_failed(); + return; + } + + // Perform device search on this channel + this->search(); + + ESP_LOGCONFIG(TAG, "Found %zu devices on channel %d", this->devices_.size(), this->channel_); +} + +void DS248xOneWireBus::dump_config() { + ESP_LOGCONFIG(TAG, "DS248x 1-Wire Bus (Channel %d):", this->channel_); + this->dump_devices_(TAG); +} + +bool DS248xOneWireBus::ensure_channel_() { + if (this->parent_ == nullptr) { + ESP_LOGE(TAG, "Parent not set!"); + return false; + } + return this->parent_->select_channel(this->channel_); +} + +int DS248xOneWireBus::reset_int() { + if (!this->ensure_channel_()) { + return -1; + } + + bool presence = false; + if (!this->parent_->ow_reset(presence)) { + return -1; + } + return presence ? 1 : 0; +} + +void DS248xOneWireBus::write8(uint8_t val) { + if (!this->ensure_channel_()) { + return; + } + if (!this->parent_->ow_write_byte(val)) { + ESP_LOGE(TAG, "Failed to write byte 0x%02X on channel %d", val, this->channel_); + } +} + +void DS248xOneWireBus::write64(uint64_t val) { + if (!this->ensure_channel_()) { + return; + } + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = static_cast(val >> (i * 8)); + if (!this->parent_->ow_write_byte(byte)) { + ESP_LOGE(TAG, "Failed to write byte %d/8 (0x%02X) on channel %d - aborting write64", i + 1, byte, this->channel_); + return; // Stop writing to prevent sending corrupted data + } + } +} + +uint8_t DS248xOneWireBus::read8() { + if (!this->ensure_channel_()) { + return 0; + } + uint8_t value = 0; + if (!this->parent_->ow_read_byte(value)) { + ESP_LOGE(TAG, "Failed to read byte on channel %d", this->channel_); + } + return value; +} + +uint64_t DS248xOneWireBus::read64() { + if (!this->ensure_channel_()) { + return 0; + } + uint64_t value = 0; + for (uint8_t i = 0; i < 8; i++) { + uint8_t byte = 0; + if (!this->parent_->ow_read_byte(byte)) { + ESP_LOGE(TAG, "Failed to read byte %d/8 on channel %d - returning partial data", i + 1, this->channel_); + return value; // Return partial data to avoid blocking, caller should validate + } + value |= (static_cast(byte) << (i * 8)); + } + return value; +} + +void DS248xOneWireBus::reset_search() { + this->search_last_discrepancy_ = 0; + this->search_last_device_flag_ = false; + this->search_address_ = 0; +} + +uint64_t DS248xOneWireBus::search_int() { + if (!this->ensure_channel_()) { + return 0; + } + + if (this->search_last_device_flag_) { + return 0; + } + + uint8_t last_zero = 0; + uint64_t address = this->search_address_; + + // Iterate through all 64 bits + for (uint8_t bit_number = 1; bit_number <= 64; bit_number++) { + uint64_t bit_mask = 1ULL << (bit_number - 1); + + // Determine search direction + bool search_direction; + if (bit_number < this->search_last_discrepancy_) { + search_direction = (address & bit_mask) != 0; + } else { + search_direction = (bit_number == this->search_last_discrepancy_); + } + + // Perform triplet operation + uint8_t status = 0; + if (!this->parent_->search_triplet(search_direction, status)) { + ESP_LOGW(TAG, "1-Wire triplet failed at bit %d on channel %d - aborting search", bit_number, this->channel_); + this->reset_search(); + return 0; + } + + bool id_bit = (status & DS248X_STATUS_SBR) != 0; + bool cmp_id_bit = (status & DS248X_STATUS_TSB) != 0; + bool dir_taken = (status & DS248X_STATUS_DIR) != 0; + + if (id_bit && cmp_id_bit) { + // No devices participating + this->reset_search(); + return 0; + } + + if (!id_bit && !cmp_id_bit && !dir_taken) { + // Discrepancy, went 0 - record position + last_zero = bit_number; + } + + // Update address based on direction taken + if (dir_taken) { + address |= bit_mask; + } else { + address &= ~bit_mask; + } + } + + // Search successful + this->search_last_discrepancy_ = last_zero; + if (last_zero == 0) { + this->search_last_device_flag_ = true; + } + this->search_address_ = address; + + return address; +} + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.h b/esphome/components/ds248x/ds248x_one_wire_bus.h new file mode 100644 index 0000000000..0591796d60 --- /dev/null +++ b/esphome/components/ds248x/ds248x_one_wire_bus.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/one_wire/one_wire_bus.h" + +namespace esphome::ds248x { + +class DS248xComponent; + +/** + * @brief OneWireBus implementation for DS248x I2C-to-1-Wire bridges. + * + * This class wraps the DS248xComponent to provide the one_wire::OneWireBus interface, + * enabling compatibility with all existing 1-Wire device components (dallas_temp, etc.). + * + * For DS2482-800, multiple instances of this class can be created (one per channel). + * For DS2482-100/DS2484, a single instance is used. + */ +class DS248xOneWireBus : public one_wire::OneWireBus, public Component { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS - 1.0f; } + + /// Set the parent DS248x component + void set_parent(DS248xComponent *parent) { this->parent_ = parent; } + + /// Set the 1-Wire channel (0-7, only relevant for DS2482-800) + void set_channel(uint8_t channel) { this->channel_ = channel; } + + /// Get the channel number + uint8_t get_channel() const { return this->channel_; } + + // OneWireBus interface implementation + int reset_int() override; + void write8(uint8_t val) override; + void write64(uint64_t val) override; + uint8_t read8() override; + uint64_t read64() override; + + protected: + void reset_search() override; + uint64_t search_int() override; + + /// Select the channel on the DS248x before any 1-Wire operation + bool ensure_channel_(); + + DS248xComponent *parent_{nullptr}; + uint8_t channel_{0}; + + // Search state + uint64_t search_address_{0}; + uint8_t search_last_discrepancy_{0}; + bool search_last_device_flag_{false}; +}; + +} // namespace esphome::ds248x diff --git a/esphome/components/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py new file mode 100644 index 0000000000..b028958132 --- /dev/null +++ b/esphome/components/ds248x/one_wire.py @@ -0,0 +1,57 @@ +"""DS248x 1-Wire Bus Platform. + +This platform creates one_wire bus instances backed by a DS248x I2C-to-1-Wire bridge. +It supports DS2482-100/101 (single channel), DS2482-800 (8 channels), and DS2484 (single channel). + +For multi-channel devices (DS2482-800), create one platform entry per channel. +Each entry becomes a separate one_wire bus that can be used by dallas_temp and other 1-Wire devices. +""" + +from esphome import final_validate as fv +import esphome.codegen as cg +from esphome.components.one_wire import OneWireBus +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType + +from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count + +CODEOWNERS = ["@tomwellnitz"] +DEPENDENCIES = ["ds248x"] + +DS248xOneWireBus = ds248x_ns.class_("DS248xOneWireBus", OneWireBus, cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(DS248xOneWireBus), + cv.GenerateID(CONF_DS248X_ID): cv.use_id(DS248xComponent), + cv.Optional(CONF_CHANNEL, default=0): cv.int_range(min=0, max=7), + } +).extend(cv.COMPONENT_SCHEMA) + + +def _final_validate(config: ConfigType) -> None: + """Validate that the channel is within the parent's channel count.""" + fconf = fv.full_config.get() + path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1] + parent_config = fconf.get_config_for_path(path) + channel_count = get_channel_count(parent_config) + channel = config[CONF_CHANNEL] + + if channel >= channel_count: + raise cv.Invalid( + f"Channel {channel} is invalid for DS248x with {channel_count} channel(s). " + f"Valid range: 0-{channel_count - 1}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_DS248X_ID]) + cg.add(var.set_parent(parent)) + cg.add(var.set_channel(config[CONF_CHANNEL])) diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 31ec1ce5b5..96a1e75668 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_, @@ -62,7 +60,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: uart_component = await cg.get_variable(config[CONF_UART_ID]) if CONF_REQUEST_PIN in config: request_pin = await cg.gpio_pin_expression(config[CONF_REQUEST_PIN]) @@ -87,10 +85,10 @@ async def to_code(config): cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.4.0") + cg.add_library("esphome/dsmr_parser", "1.9.0") -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() for uart_conf in full_config["uart"]: @@ -104,7 +102,5 @@ def final_validate(config: ConfigType) -> ConfigType: ) break - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/dsmr/dsmr.cpp b/esphome/components/dsmr/dsmr.cpp index 2fa51f73af..9580464a2e 100644 --- a/esphome/components/dsmr/dsmr.cpp +++ b/esphome/components/dsmr/dsmr.cpp @@ -153,8 +153,9 @@ void Dsmr::receive_encrypted_telegram_() { bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) { this->stop_requesting_data_(); - ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size()); - ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.content().size()), telegram.content().data()); + ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size()); + ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast(telegram.full_content().size()), + telegram.full_content().data()); MyData data; if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) { @@ -167,7 +168,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) // Publish the telegram, after publishing the sensors so it can also trigger action based on latest values if (this->s_telegram_ != nullptr) { - this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size()); + this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size()); } return true; } diff --git a/esphome/components/dsmr/dsmr.h b/esphome/components/dsmr/dsmr.h index 626a389c1f..321fbab824 100644 --- a/esphome/components/dsmr/dsmr.h +++ b/esphome/components/dsmr/dsmr.h @@ -16,9 +16,14 @@ #include #include +// On ESP8266 Arduino, BearSSL is the native crypto. The mbedtls headers can +// still be in scope when a sibling component (e.g. wireguard) pulls in +// esp_mbedtls_esp8266, but that build leaves MBEDTLS_GCM_C disabled so the +// gcm.h symbols are unresolved at link time. Force BearSSL on ESP8266 to +// avoid that linker error. #if __has_include() #include -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() #if __has_include() #include #endif @@ -33,7 +38,7 @@ namespace esphome::dsmr { #if __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmTfPsa; -#elif __has_include() +#elif !defined(USE_ESP8266) && __has_include() using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmMbedTls; #else using Aes128GcmDecryptorImpl = dsmr_parser::Aes128GcmBearSsl; @@ -60,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) @@ -69,7 +74,8 @@ class Dsmr : public Component, public uart::UARTDevice { receive_timeout_(receive_timeout), request_pin_(request_pin), buffer_(max_telegram_length), - packet_accumulator_(buffer_, crc_check) { + packet_accumulator_(buffer_, crc_check), + dlms_decryptor_(gcm_decryptor_, crc_check) { this->set_decryption_key_(decryption_key); } @@ -92,7 +98,11 @@ class Dsmr : public Component, public uart::UARTDevice { // Remove before 2026.8.0 ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0") - void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); } + void set_decryption_key(const std::string &decryption_key) { + // Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols, + // otherwise `Aes128GcmDecryptionKey::from_hex` will fail. + this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str()); + } // Sensor setters #define DSMR_SET_SENSOR(s) \ @@ -138,7 +148,7 @@ class Dsmr : public Component, public uart::UARTDevice { std::vector buffer_; dsmr_parser::PacketAccumulator packet_accumulator_; Aes128GcmDecryptorImpl gcm_decryptor_; - dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_}; + dsmr_parser::DlmsPacketDecryptor dlms_decryptor_; std::array uart_chunk_reading_buf_; }; } // namespace esphome::dsmr diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 292e5a1156..6aecc62d6b 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -27,6 +27,7 @@ from esphome.const import ( UNIT_SECOND, UNIT_VOLT, ) +from esphome.types import ConfigType from . import CONF_DSMR_ID, Dsmr @@ -248,10 +249,6 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), - cv.Optional("electricity_switch_position"): sensor.sensor_schema( - accuracy_decimals=3, - state_class=STATE_CLASS_MEASUREMENT, - ), cv.Optional("electricity_failures"): sensor.sensor_schema( accuracy_decimals=0, state_class=STATE_CLASS_MEASUREMENT, @@ -808,11 +805,15 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_DURATION, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional("electricity_switch_position"): cv.invalid( + "'electricity_switch_position' has moved to the 'text_sensor' platform." + "Move it under 'text_sensor' to fix." + ), } ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DSMR_ID]) sensors = [] diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index a8f29c7ca8..4945ba965c 100644 --- a/esphome/components/dsmr/text_sensor.py +++ b/esphome/components/dsmr/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_INTERNAL +from esphome.types import ConfigType from . import CONF_DSMR_ID, Dsmr @@ -14,6 +15,7 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional("p1_version"): text_sensor.text_sensor_schema(), cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(), cv.Optional("timestamp"): text_sensor.text_sensor_schema(), + cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(), cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(), cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(), @@ -38,7 +40,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DSMR_ID]) text_sensors = [] 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_cycle/sensor.py b/esphome/components/duty_cycle/sensor.py index 37c889cd85..b7aa1777c8 100644 --- a/esphome/components/duty_cycle/sensor.py +++ b/esphome/components/duty_cycle/sensor.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import CONF_PIN, ICON_PERCENT, STATE_CLASS_MEASUREMENT, UNIT_PERCENT +from esphome.types import ConfigType duty_cycle_ns = cg.esphome_ns.namespace("duty_cycle") DutyCycleSensor = duty_cycle_ns.class_( @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) 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/duty_time/sensor.py b/esphome/components/duty_time/sensor.py index 456859f8e4..6d878a80a5 100644 --- a/esphome/components/duty_time/sensor.py +++ b/esphome/components/duty_time/sensor.py @@ -19,6 +19,9 @@ from esphome.const import ( STATE_CLASS_TOTAL_INCREASING, UNIT_SECOND, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CONF_LAST_TIME = "last_time" @@ -66,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) cg.add(var.set_restore(config[CONF_RESTORE])) @@ -93,7 +96,12 @@ DUTY_TIME_ID_SCHEMA = maybe_simple_id( @register_action( "sensor.duty_time.start", StartAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_start_to_code(config, action_id, template_arg, args): +async def sensor_runtime_start_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -102,7 +110,12 @@ async def sensor_runtime_start_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.stop", StopAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): +async def sensor_runtime_stop_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -111,7 +124,12 @@ async def sensor_runtime_stop_to_code(config, action_id, template_arg, args): @register_action( "sensor.duty_time.reset", ResetAction, DUTY_TIME_ID_SCHEMA, synchronous=True ) -async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): +async def sensor_runtime_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -120,7 +138,12 @@ async def sensor_runtime_reset_to_code(config, action_id, template_arg, args): @register_condition( "sensor.duty_time.is_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @@ -128,6 +151,11 @@ async def duty_time_is_running_to_code(config, condition_id, template_arg, args) @register_condition( "sensor.duty_time.is_not_running", RunningCondition, DUTY_TIME_ID_SCHEMA ) -async def duty_time_is_not_running_to_code(config, condition_id, template_arg, args): +async def duty_time_is_not_running_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index a1a8e0aec5..3b1eb99e60 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -3,6 +3,9 @@ from esphome.components.light.effects import register_addressable_effect from esphome.components.light.types import AddressableLightEffect import esphome.config_validation as cv from esphome.const import CONF_CHANNELS, CONF_ID, CONF_METHOD, CONF_NAME +from esphome.core import ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] @@ -32,7 +35,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add(var.set_method(METHODS[config[CONF_METHOD]])) @@ -48,7 +51,7 @@ async def to_code(config): cv.Optional(CONF_CHANNELS, default="RGB"): cv.one_of(*CHANNELS, upper=True), }, ) -async def e131_light_effect_to_code(config, effect_id): +async def e131_light_effect_to_code(config: ConfigType, effect_id: ID) -> MockObj: parent = await cg.get_variable(config[CONF_E131_ID]) effect = cg.new_Pvariable(effect_id, config[CONF_NAME]) diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index bfcb0ca7f8..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(); @@ -52,6 +52,8 @@ class E131Component : public esphome::Component { if (!this->udp_.parsePacket()) return -1; return this->udp_.read(buf, len); +#else + return -1; #endif } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); 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/ee895/sensor.py b/esphome/components/ee895/sensor.py index 8c9c7e7238..fdad47fb05 100644 --- a/esphome/components/ee895/sensor.py +++ b/esphome/components/ee895/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( UNIT_HECTOPASCAL, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType CODEOWNERS = ["@Stock-M"] @@ -51,7 +52,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ektf2232/touchscreen/__init__.py b/esphome/components/ektf2232/touchscreen/__init__.py index 64bb17a7db..7636b6993a 100644 --- a/esphome/components/ektf2232/touchscreen/__init__.py +++ b/esphome/components/ektf2232/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] @@ -29,7 +30,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/emc2101/__init__.py index 323195e99a..639847345f 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INVERTED, CONF_RESOLUTION +from esphome.types import ConfigType CODEOWNERS = ["@ellull"] @@ -68,7 +69,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/emc2101/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index 464f49fe51..f46082f5e7 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() { return NAN; } - // join msb and lsb (5 least significant bits are not used) - uint16_t raw = (msb << 8 | lsb) >> 5; - return raw * 0.125; + // join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t + int16_t raw = static_cast((msb << 8) | lsb) >> 5; + return raw * 0.125f; } float Emc2101Component::get_speed() { 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/__init__.py b/esphome/components/emc2101/output/__init__.py index 586f0800a6..a8820345e2 100644 --- a/esphome/components/emc2101/output/__init__.py +++ b/esphome/components/emc2101/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await output.register_output(var, config) 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/__init__.py b/esphome/components/emc2101/sensor/__init__.py index b6a2c8a333..cc8901cf38 100644 --- a/esphome/components/emc2101/sensor/__init__.py +++ b/esphome/components/emc2101/sensor/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA, emc2101_ns @@ -53,7 +54,7 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend( ).extend(cv.polling_component_schema("60s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_EMC2101_ID]) var = cg.new_Pvariable(config[CONF_ID], paren) await cg.register_component(var, config) 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/climate.py b/esphome/components/emmeti/climate.py index 56e8e2b804..ea44606300 100644 --- a/esphome/components/emmeti/climate.py +++ b/esphome/components/emmeti/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType CODEOWNERS = ["@E440QF"] AUTO_LOAD = ["climate_ir"] @@ -10,5 +11,5 @@ EmmetiClimate = emmeti_ns.class_("EmmetiClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(EmmetiClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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/__init__.py b/esphome/components/emontx/__init__.py index a2d4349698..3821f3e10e 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_RX_BUFFER_SIZE, CONF_UART_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -59,7 +60,7 @@ CONFIG_SCHEMA = ( ) -def final_validate(config: ConfigType) -> ConfigType: +def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Count sensors registered to this hub (IDs are resolved at final_validate stage) @@ -95,7 +96,7 @@ def final_validate(config: ConfigType) -> ConfigType: parity="NONE", stop_bits=1, ) - return schema(config) + schema(config) FINAL_VALIDATE_SCHEMA = final_validate @@ -115,14 +116,16 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - # Initialize sensor storage with count from final_validate + # Initialize sensor storage with count from final_validate before any + # await, so platform to_code() calls always see it initialized + # regardless of YAML key order. sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0) if sensor_count > 0: cg.add(var.init_sensors(sensor_count)) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) @@ -143,8 +146,11 @@ EMONTX_SEND_COMMAND_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def emontx_send_command_action_to_code( - config: ConfigType, action_id, template_arg, args -) -> None: + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_COMMAND], args, cg.std_string) 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/__init__.py b/esphome/components/emontx/sensor/__init__.py index 83a972c5e0..56a7fb8b55 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -7,8 +7,10 @@ from esphome.const import ( CONF_ID, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_TEMPERATURE, @@ -18,8 +20,10 @@ from esphome.const import ( UNIT_AMPERE, UNIT_CELSIUS, UNIT_EMPTY, + UNIT_HERTZ, UNIT_PULSES, UNIT_VOLT, + UNIT_VOLT_AMPS, UNIT_WATT, UNIT_WATT_HOURS, ) @@ -29,6 +33,32 @@ from .. import CONF_EMONTX_ID, CONF_TAG_NAME, EmonTx, emontx_ns EmonTxSensor = emontx_ns.class_("EmonTxSensor", sensor.Sensor, cg.Component) +# Known emonTx/avrdb JSON tag conventions, gathered from real firmware +# (see https://github.com/openenergymonitor/avrdb_firmware), used to decide +# whether each tag below requires a numeric index or may also appear bare: +# +# Tag family Bare (no index) Numeric-indexed +# ----------- ----------------------- ---------------------------------- +# P (power) no P1, P2, ... (multi-channel boards) +# E (energy) no E1, E2, ... +# V (voltage) Vrms (NOT matched here, V1, V2, V3 (per-phase boards) +# doesn't fit "V"+digits) +# I (current) no I1, I2, ... +# T (temp.) no T1, T2, ... +# F (frequency) F (single mains freq.) not seen indexed +# PULSE pulse (single-CT boards) PULSE1, PULSE2, ... (other variants) +# PF (power not seen bare PF1, PF2, ... (currently unused/ +# factor) commented out in avrdb firmware) +# AP (apparent not seen bare AP1, AP2, ... (not an avrdb tag at +# power) all; avrdb uses "VA"+index instead, +# itself currently unused/commented +# out; "AP" is kept here for other +# firmware/integrations using it) +# +# This is why a bare "PULSE" resolves to proper defaults below, but bare +# "PF"/"AP" fall back to generic defaults instead: only PULSE has a +# confirmed bare-tag use in real, currently-shipping firmware. + # Define sensor type configurations by prefix SENSOR_CONFIGS = { "P": { @@ -63,11 +93,30 @@ SENSOR_CONFIGS = { }, } -# Pattern-based configurations +# Tags reported once, without a numeric index (e.g. "F"), matched exactly +# rather than by prefix. +EXACT_TAG_CONFIGS = { + "F": { + CONF_UNIT_OF_MEASUREMENT: UNIT_HERTZ, + CONF_DEVICE_CLASS: DEVICE_CLASS_FREQUENCY, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, +} + +# Pattern-based configurations. The remainder after the prefix must be a +# non-empty numeric index (like V1/I1/E1), so e.g. "APPLE" doesn't collide +# with the "AP" prefix and a bare "PF"/"AP" (no index) doesn't match. +# "PULSE" is the exception: some emonTx firmware (e.g. avrdb-based single-CT +# variants) reports a single pulse counter as a bare "pulse" tag with no +# numeric index at all, so that pattern also accepts an empty suffix. +PATTERNS_ALLOWING_BARE_TAG = {"PULSE"} + PATTERN_CONFIGS = { "PULSE": { CONF_UNIT_OF_MEASUREMENT: UNIT_PULSES, CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, CONF_ACCURACY_DECIMALS: 0, }, "PF": { @@ -76,14 +125,22 @@ PATTERN_CONFIGS = { CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, CONF_ACCURACY_DECIMALS: 2, }, + "AP": { + CONF_UNIT_OF_MEASUREMENT: UNIT_VOLT_AMPS, + CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER, + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 2, + }, } -# Create a base schema that's flexible for any tag -BASE_SCHEMA = sensor.sensor_schema( - EmonTxSensor, - state_class=STATE_CLASS_MEASUREMENT, - accuracy_decimals=0, -).extend( +# BASE_SCHEMA intentionally omits state_class and accuracy_decimals defaults. +# Passing them to sensor_schema() would register them via cv.Optional(key, default=...), +# making them always present in the validated config dict and preventing +# apply_tag_defaults from overriding them with the correct per-prefix values. +# They are injected by apply_tag_defaults below, after running through the +# same validators sensor_schema() would use (see _DEFAULT_VALIDATORS) so the +# values are code-generation-ready. +BASE_SCHEMA = sensor.sensor_schema(EmonTxSensor).extend( { cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), cv.Required(CONF_TAG_NAME): cv.string, @@ -91,34 +148,56 @@ BASE_SCHEMA = sensor.sensor_schema( ) +_DEFAULT_VALIDATORS = { + CONF_STATE_CLASS: sensor.validate_state_class, + CONF_DEVICE_CLASS: sensor.validate_device_class, + CONF_UNIT_OF_MEASUREMENT: sensor.validate_unit_of_measurement, +} + + +def _apply_defaults(config: ConfigType, defaults: dict) -> None: + """Inject defaults into config, skipping keys already set by the user. + Values are run through the same validators sensor_schema() would use, so + they are code-generation-ready and a typo'd constant fails validation + instead of shipping silently.""" + for key, value in defaults.items(): + if key not in config: + if key in _DEFAULT_VALIDATORS: + value = _DEFAULT_VALIDATORS[key](value) + config[key] = value + + def apply_tag_defaults(config: ConfigType) -> ConfigType: """Apply defaults based on tag prefix if applicable, but don't restrict any tags.""" tag = config[CONF_TAG_NAME] - - # Skip if tag is too short - if len(tag) < 2: - return config - - # Check if this tag starts with a known prefix tag_upper = tag.upper() + if (exact_config := EXACT_TAG_CONFIGS.get(tag_upper)) is not None: + _apply_defaults(config, exact_config) + return config + for pattern, pattern_config in PATTERN_CONFIGS.items(): - if tag_upper.startswith(pattern): - # Apply pattern defaults if not overridden by user - for key, value in pattern_config.items(): - if key not in config: - config[key] = value + suffix = tag_upper[len(pattern) :] + bare_ok = not suffix and pattern in PATTERNS_ALLOWING_BARE_TAG + if tag_upper.startswith(pattern) and (suffix.isdigit() or bare_ok): + _apply_defaults(config, pattern_config) return config - # Only apply defaults for known prefixes with numeric indices - prefix = tag_upper[0] - if prefix in SENSOR_CONFIGS and len(tag) > 1 and tag[1:].isdigit(): - # Apply defaults for known tag types, but only if not overridden by user - defaults = SENSOR_CONFIGS[prefix] - for key, value in defaults.items(): - if key not in config: - config[key] = value + # Only apply defaults for known prefixes with numeric indices (e.g. E1, V2, T3) + if len(tag) >= 2: + prefix = tag_upper[0] + if prefix in SENSOR_CONFIGS and tag[1:].isdigit(): + _apply_defaults(config, SENSOR_CONFIGS[prefix]) + return config + # Fall back to generic defaults for tags with no known prefix + _apply_defaults( + config, + { + CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT, + CONF_ACCURACY_DECIMALS: 0, + }, + ) return config 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/cover.py b/esphome/components/endstop/cover.py index c16680b6af..0e27189500 100644 --- a/esphome/components/endstop/cover.py +++ b/esphome/components/endstop/cover.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_OPEN_ENDSTOP, CONF_STOP_ACTION, ) +from esphome.types import ConfigType endstop_ns = cg.esphome_ns.namespace("endstop") EndstopCover = endstop_ns.class_("EndstopCover", cover.Cover, cg.Component) @@ -34,7 +35,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) 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_base/__init__.py b/esphome/components/ens160_base/__init__.py index 46c53c3b10..1bdfb0c0a6 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( UNIT_PARTS_PER_BILLION, UNIT_PARTS_PER_MILLION, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@vincentscode", "@latonita"] @@ -57,7 +59,7 @@ CONFIG_SCHEMA_BASE = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code_base(config): +async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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_i2c/sensor.py b/esphome/components/ens160_i2c/sensor.py index cad4e81afc..398b9b4804 100644 --- a/esphome/components/ens160_i2c/sensor.py +++ b/esphome/components/ens160_i2c/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c +from esphome.types import ConfigType from ..ens160_base import CONFIG_SCHEMA_BASE, cv, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend( ).extend({cv.GenerateID(): cv.declare_id(ENS160I2CComponent)}) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await i2c.register_i2c_device(var, config) 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/ens160_spi/sensor.py b/esphome/components/ens160_spi/sensor.py index 1bda05c7bb..cc6a90a33e 100644 --- a/esphome/components/ens160_spi/sensor.py +++ b/esphome/components/ens160_spi/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import spi +from esphome.types import ConfigType from ..ens160_base import CONFIG_SCHEMA_BASE, cv, to_code_base @@ -18,6 +19,6 @@ CONFIG_SCHEMA = CONFIG_SCHEMA_BASE.extend(spi.spi_device_schema()).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await to_code_base(config) await spi.register_spi_device(var, config) 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/ens210/sensor.py b/esphome/components/ens210/sensor.py index 289a559673..bfd758f92f 100644 --- a/esphome/components/ens210/sensor.py +++ b/esphome/components/ens210/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@itn3rd77"] DEPENDENCIES = ["i2c"] @@ -44,7 +45,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index a2ea28f4b6..7b7c48c0b0 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -64,4 +64,151 @@ 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); +} + +/** Map RGB color to discrete BWYRGB hex 6 color key + * + * Divides the RGB cube into 8 corners by which components are "on" (over 128), same as + * color_to_bwyr, but also resolves the green and blue corners instead of folding them into + * white/black. + * + * @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_yellow Native value for yellow + * @param hw_red Native value for red + * @param hw_green Native value for green + * @param hw_blue Native value for blue + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, + NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green, + NATIVE_COLOR hw_blue) { + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on && g_on && !b_on) { + return hw_yellow; + } + if (r_on && !g_on && !b_on) { + return hw_red; + } + if (!r_on && g_on && !b_on) { + return hw_green; + } + if (!r_on && !g_on && b_on) { + return hw_blue; + } + // Handle "impure" colors (cyan, magenta) by folding into the closest primary. + if (!r_on && g_on && b_on) { + return hw_green; // cyan + } + if (r_on && !g_on) { + return hw_red; // magenta + } + if (r_on) { + // All high (but not gray) -> white + return hw_white; + } + // !r_on && !g_on && !b_on + // All low (but not gray) -> black + return hw_black; +} + +/** Map RGB color to discrete BWYRGBO hex 7 color key + * + * Same corner logic as color_to_bwyrgb, except the red/yellow corner is split three ways + * instead of two, for panels with a dedicated orange ink. + * + * @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_yellow Native value for yellow + * @param hw_red Native value for red + * @param hw_green Native value for green + * @param hw_blue Native value for blue + * @param hw_orange Native value for orange + * @return Converted native hardware color value + * @internal Constexpr. Does not depend on side effects ("pure"). + */ +template +constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, + NATIVE_COLOR hw_yellow, NATIVE_COLOR hw_red, NATIVE_COLOR hw_green, + NATIVE_COLOR hw_blue, NATIVE_COLOR hw_orange) { + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + if ((static_cast(color.r) + color.g + color.b) > 382) { + return hw_white; + } + return hw_black; + } + + const bool r_on = (color.r > 128); + const bool g_on = (color.g > 128); + const bool b_on = (color.b > 128); + + if (r_on && !b_on) { + // Between red and yellow: split the gradient in three instead of two, since this panel has a + // dedicated orange ink. Named orange (e.g. 0xFFA500) has g close to the midpoint, so the + // plain g_on (>128) threshold used by color_to_bwyrgb can't tell it apart from yellow. + if (color.g > 170) { + return hw_yellow; + } + if (color.g > 85) { + return hw_orange; + } + return hw_red; + } + if (!r_on && g_on && !b_on) { + return hw_green; + } + if (!r_on && !g_on && b_on) { + return hw_blue; + } + // Handle "impure" colors (cyan, magenta) by folding into the closest primary. + if (!r_on && g_on && b_on) { + return hw_green; // cyan + } + if (r_on && !g_on) { + return hw_red; // magenta + } + if (r_on) { + // All high (but not gray) -> white + return hw_white; + } + // !r_on && !g_on && !b_on + // All low (but not gray) -> black + return hw_black; +} + } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 658f9e2c4a..e9da924de5 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -5,10 +5,15 @@ from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation -from esphome.components.mipi import flatten_sequence, map_sequence +from esphome.components.mipi import ( + flatten_sequence, + map_sequence, + model_schema_extractor, +) 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, @@ -107,10 +112,12 @@ def model_schema(config): cv.positive_time_period_milliseconds, cv.Range(max=core.TimePeriod(milliseconds=500)), ), + **model.get_config_options(), } ) +@model_schema_extractor(MODELS, model_schema) def customise_schema(config): """ Create a customised config schema for a specific model and validate the configuration. @@ -124,13 +131,29 @@ 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 -def _final_validate(config): +def _final_validate(config) -> None: spi.final_validate_device_schema( "epaper_spi", require_miso=False, require_mosi=True )(config) @@ -147,7 +170,6 @@ def _final_validate(config): config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") - return config FINAL_VALIDATE_SCHEMA = _final_validate @@ -176,6 +198,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]) @@ -192,6 +215,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_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp new file mode 100644 index 0000000000..820dc3c77a --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -0,0 +1,82 @@ +#include "epaper_spi_4bpp.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.4bpp"; + +void EPaper4bpp::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + + auto pixel_color = this->color_to_native(color); + + // We store 2 pixels per byte + this->buffer_.fill(pixel_color + (pixel_color << 4)); + + // Whole buffer just changed; mark the entire canvas dirty. + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; +} + +void EPaper4bpp::clear() { + // clear buffer to white, just like real paper. + this->fill(COLOR_ON); +} + +void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + auto pixel_bits = this->color_to_native(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); + } +} + +bool HOT EPaper4bpp::transfer_data() { + const uint32_t start_time = App.get_loop_component_start_time(); + const size_t buffer_length = this->buffer_length_; + if (this->current_data_index_ == 0) { + this->command(CMD_TRANSFER_DATA); + } + + size_t buf_idx = 0; + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + while (this->current_data_index_ != buffer_length) { + 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(); + ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); + buf_idx = 0; + + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + return false; + } + } + } + // Finished the entire dataset + if (buf_idx != 0) { + this->start_data_(); + this->write_array(bytes_to_send, buf_idx); + this->disable(); + } + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h new file mode 100644 index 0000000000..6eebe24c91 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -0,0 +1,35 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte). + * + * Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by + * this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via + * color_to_native() plus their IC-specific power/refresh/sleep command sequences. + */ +class EPaper4bpp : public EPaperBase { + public: + EPaper4bpp(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_ = width * height / 2; // 2 pixels per byte + } + + void fill(Color color) override; + void clear() override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + + /// Map an RGB color to this panel's native 4-bit color key (low nibble). + virtual uint8_t color_to_native(Color color) = 0; + + static constexpr uint8_t CMD_TRANSFER_DATA = 0x10; +}; + +} // namespace esphome::epaper_spi 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_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp new file mode 100644 index 0000000000..b81542b6c1 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp @@ -0,0 +1,47 @@ +// Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR) + +#include "epaper_spi_inkplate6color.h" +#include "colorconv.h" + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.inkplate6color"; + +// Native hardware color codes for this panel's 4-bit color values. +enum Inkplate6ColorHex : uint8_t { + BLACK = 0, + WHITE = 1, + GREEN = 2, + BLUE = 3, + RED = 4, + YELLOW = 5, + ORANGE = 6, +}; + +uint8_t EPaperInkplate6Color::color_to_native(Color color) { + return color_to_bwyrgbo(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE, ORANGE); +} + +void EPaperInkplate6Color::power_on() { + ESP_LOGV(TAG, "Power on"); + this->command(0x04); +} + +void EPaperInkplate6Color::power_off() { + ESP_LOGV(TAG, "Power off"); + this->command(0x02); +} + +void EPaperInkplate6Color::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh"); // full refresh only; partial is unused + this->cmd_data(0x12, {0x00}); +} + +void EPaperInkplate6Color::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.h b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h new file mode 100644 index 0000000000..8b00552ff8 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.h @@ -0,0 +1,22 @@ +#pragma once + +#include "epaper_spi_4bpp.h" + +namespace esphome::epaper_spi { + +// Soldered Inkplate 6COLOR: 600x448 7-color (black/white/green/blue/red/yellow/orange) e-paper, +// UC8159-family controller. +class EPaperInkplate6Color final : public EPaper4bpp { + public: + using EPaper4bpp::EPaper4bpp; + + protected: + uint8_t color_to_native(Color color) 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_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index ee117304c4..fffb2b5e84 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -14,10 +14,9 @@ void EPaperMono::refresh_screen(bool partial) { } void EPaperMono::deep_sleep() { - ESP_LOGV(TAG, "Deep sleep"); - if (this->is_using_partial_update_()) { - this->cmd_data(0x10, {0x00}); // sleep in power on mode - } else { + // Deep sleep loses RAM so cannot be used with partial update + if (!this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x10, {0x03}); // deep sleep } } diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 1ef2dd12c3..f47d37d550 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -1,76 +1,23 @@ #include "epaper_spi_spectra_e6.h" - -#include +#include "colorconv.h" #include "esphome/core/log.h" namespace esphome::epaper_spi { static constexpr const char *const TAG = "epaper_spi.6c"; -static constexpr unsigned char GRAY_THRESHOLD = 50; -enum E6Color { - BLACK, - WHITE, - YELLOW, - RED, - SKIP_1, - BLUE, - GREEN, - CYAN, - SKIP_2, +// Native hardware color codes for this panel's 4-bit color values. +enum E6Color : uint8_t { + BLACK = 0, + WHITE = 1, + YELLOW = 2, + RED = 3, + BLUE = 5, + GREEN = 6, }; -static uint8_t color_to_hex(Color color) { - // --- Step 1: Check for Grayscale (Black or White) --- - // We define "grayscale" as a color where the min and max components - // are close to each other. - unsigned char max_rgb = std::max({color.r, color.g, color.b}); - unsigned char min_rgb = std::min({color.r, color.g, color.b}); - - if ((max_rgb - min_rgb) < GRAY_THRESHOLD) { - // It's a shade of gray. Map to BLACK or WHITE. - // We split the luminance at the halfway point (382 = (255*3)/2) - if ((static_cast(color.r) + color.g + color.b) > 382) { - return WHITE; - } - return BLACK; - } - // --- Step 2: Check for Primary/Secondary Colors --- - // If it's not gray, it's a color. We check which components are - // "on" (over 128) vs "off". This divides the RGB cube into 8 corners. - 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 YELLOW; - } - if (r_on && !g_on && !b_on) { - return RED; - } - if (!r_on && g_on && !b_on) { - return GREEN; - } - if (!r_on && !g_on && b_on) { - return BLUE; - } - // Handle "impure" colors (Cyan, Magenta) - if (!r_on && g_on && b_on) { - // Cyan (G+B) -> Closest is Green or Blue. Pick Green. - return GREEN; - } - if (r_on && !g_on) { - // Magenta (R+B) -> Closest is Red or Blue. Pick Red. - return RED; - } - // Handle the remaining corners (White-ish, Black-ish) - if (r_on) { - // All high (but not gray) -> White - return WHITE; - } - // !r_on && !g_on && !b_on - // All low (but not gray) -> Black - return BLACK; +uint8_t EPaperSpectraE6::color_to_native(Color color) { + return color_to_bwyrgb(color, BLACK, WHITE, YELLOW, RED, GREEN, BLUE); } void EPaperSpectraE6::power_on() { @@ -92,71 +39,4 @@ void EPaperSpectraE6::deep_sleep() { ESP_LOGV(TAG, "Deep sleep"); this->cmd_data(0x07, {0xA5}); } - -void EPaperSpectraE6::fill(Color color) { - // If clipping is active, fall back to base implementation - if (this->get_clipping().is_set()) { - EPaperBase::fill(color); - return; - } - - auto pixel_color = color_to_hex(color); - - // We store 2 pixels per byte - this->buffer_.fill(pixel_color + (pixel_color << 4)); -} - -void EPaperSpectraE6::clear() { - // clear buffer to white, just like real paper. - this->fill(COLOR_ON); -} - -void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) { - if (!this->rotate_coordinates_(x, y)) - return; - auto pixel_bits = color_to_hex(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); - } -} - -bool HOT EPaperSpectraE6::transfer_data() { - const uint32_t start_time = App.get_loop_component_start_time(); - const size_t buffer_length = this->buffer_length_; - if (this->current_data_index_ == 0) { - this->command(0x10); - } - - size_t buf_idx = 0; - uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; - while (this->current_data_index_ != buffer_length) { - 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(); - ESP_LOGV(TAG, "Wrote %d bytes at %ums", buf_idx, (unsigned) millis()); - buf_idx = 0; - - if (millis() - start_time > MAX_TRANSFER_TIME) { - // Let the main loop run and come back next loop - return false; - } - } - } - // Finished the entire dataset - if (buf_idx != 0) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - } - this->current_data_index_ = 0; - return true; -} } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index 9c251068af..e1e3d3d763 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -1,28 +1,20 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_4bpp.h" namespace esphome::epaper_spi { -class EPaperSpectraE6 final : public EPaperBase { +class EPaperSpectraE6 final : public EPaper4bpp { public: - EPaperSpectraE6(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_ = width * height / 2; // 2 pixels per byte - } - - void fill(Color color) override; - void clear() override; + using EPaper4bpp::EPaper4bpp; protected: + uint8_t color_to_native(Color color) override; + void refresh_screen(bool partial) override; void power_on() override; void power_off() override; void deep_sleep() override; - void draw_pixel_at(int x, int y, Color color) override; - - bool transfer_data() override; }; } // 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..95d1fcb484 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -0,0 +1,373 @@ +#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) { + break; + } + } + if (half < total_rows) { + 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) { + break; + } + } + if (half < total_rows * 2) { + 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..34e65061f3 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -2,16 +2,20 @@ 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, class_name: str, - initsequence=None, + initsequence=(), **defaults, ): name = name.upper() @@ -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/inkplate6color.py b/esphome/components/epaper_spi/models/inkplate6color.py new file mode 100644 index 0000000000..33fecab848 --- /dev/null +++ b/esphome/components/epaper_spi/models/inkplate6color.py @@ -0,0 +1,57 @@ +# Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate6COLOR) + +from esphome.components.mipi import delay + +from . import EpaperModel + + +class Inkplate6ColorModel(EpaperModel): + def __init__(self, name, class_name="EPaperInkplate6Color", **kwargs): + super().__init__(name, class_name, **kwargs) + + # fmt: off + def get_init_sequence(self, config: dict): + width, height = self.get_dimensions(config) + return ( + (0x00, 0xEF, 0x08,), # panel setting + (0x01, 0x37, 0x00, 0x05, 0x05,), # power setting + (0x03, 0x00,), # power off sequence setting + (0x06, 0xC7, 0xC7, 0x1D,), # booster soft start + (0x41, 0x00,), # temperature sensor enable + (0x50, 0x37,), # VCOM and data interval + (0x60, 0x20,), # TCON setting + (0x61, width // 256, width % 256, height // 256, height % 256,), # resolution set + (0xE3, 0xAA,), # power saving + delay(100), + (0x50, 0x37,), # VCOM and data interval, resent once the power-saving setting settles + ) + + +# Native orientation is landscape (600x448). +inkplate6color = Inkplate6ColorModel( + "inkplate6color", + width=600, + height=448, + # Vendor library drives the panel at 2MHz; the controller doesn't reliably support faster rates. + data_rate="2MHz", + # Vendor library waits 200ms after releasing reset before talking to the panel. + reset_duration="200ms", + # A full 7-color refresh takes tens of seconds; disallow faster updates to avoid FSM update loops. + minimum_update_interval="30s", + # Panel's native buffer orientation is rotated 180 degrees relative to the logical + # rotation=0 orientation; confirmed on real hardware. + mirror_x=True, + mirror_y=True, + # Default GPIO pins for the on-board Inkplate 6COLOR wiring. + reset_pin=19, + dc_pin=33, + cs_pin=27, + 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/audio_adc.py b/esphome/components/es7210/audio_adc.py index f0bd8bc25a..2defdb0c35 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.audio_adc import AudioAdc import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["i2c"] @@ -41,7 +42,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/audio_adc.py b/esphome/components/es7243e/audio_adc.py index c305d60172..4916133982 100644 --- a/esphome/components/es7243e/audio_adc.py +++ b/esphome/components/es7243e/audio_adc.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.audio_adc import AudioAdc import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MIC_GAIN +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/audio_dac.py b/esphome/components/es8156/audio_dac.py index c5fb6096da..305aa92125 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -4,6 +4,7 @@ from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_AUDIO_DAC, CONF_BITS_PER_SAMPLE, CONF_ID import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["i2c"] @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Check all speaker configurations for ones that reference this es8156 @@ -45,7 +46,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/audio_dac.py b/esphome/components/es8311/audio_dac.py index 5941a81935..f9cfb822cc 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.types import ConfigType CODEOWNERS = ["@kroimon", "@kahrendt"] DEPENDENCIES = ["i2c"] @@ -55,7 +56,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/audio_dac.py b/esphome/components/es8388/audio_dac.py index 77e07b2e01..2616cbfa53 100644 --- a/esphome/components/es8388/audio_dac.py +++ b/esphome/components/es8388/audio_dac.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@P4uLT"] CONF_ES8388_ID = "es8388_id" @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index c015393e14..0b97240230 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) { ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value)); ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value); + // Only toggle the DACMute bit; the other bits of this register hold unrelated + // DAC settings that must be preserved. Previously muting overwrote the whole + // register with 0x3C and unmuting never cleared the bit, so once muted the DAC + // could not be unmuted again. if (mute_state) { - value = 0x3C; + value |= ES8388_DACCONTROL3_DAC_MUTE; + } else { + value &= ~ES8388_DACCONTROL3_DAC_MUTE; } ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state)); 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/es8388_const.h b/esphome/components/es8388/es8388_const.h index 451c9cc026..e081c55dbd 100644 --- a/esphome/components/es8388/es8388_const.h +++ b/esphome/components/es8388/es8388_const.h @@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16; static const uint8_t ES8388_DACCONTROL1 = 0x17; static const uint8_t ES8388_DACCONTROL2 = 0x18; static const uint8_t ES8388_DACCONTROL3 = 0x19; +static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3 static const uint8_t ES8388_DACCONTROL4 = 0x1a; static const uint8_t ES8388_DACCONTROL5 = 0x1b; static const uint8_t ES8388_DACCONTROL6 = 0x1c; diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index 068d9f9fb8..b81bcd13cf 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401 +from esphome.types import ConfigType from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_ES8388_ID]) if dac_output_config := config.get(CONF_DAC_OUTPUT): s = await select.new_select( 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 221c84c149..bc91f29a42 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,3 +1,4 @@ +from collections.abc import Callable, Iterable import contextlib from dataclasses import dataclass import itertools @@ -6,9 +7,12 @@ import os from pathlib import Path import re import subprocess +from typing import Any from esphome import yaml_util import esphome.codegen as cg +from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_PATH, CONF_PLATFORM_VERSION, CONF_PLATFORMIO_OPTIONS, + CONF_PROJECT, CONF_REF, CONF_SAFE_MODE, CONF_SIZE, @@ -46,19 +51,21 @@ from esphome.const import ( Toolchain, __version__, ) -from esphome.core import CORE, HexInt, Library +from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.espidf.component import generate_idf_component +from esphome.espidf.component import generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from esphome.writer import clean_build, clean_cmake_cache from .boards import BOARDS, STANDARD_BOARDS -from .const import ( # noqa +from .const import ( KEY_ARDUINO_LIBRARIES, KEY_BOARD, + KEY_CERT_BUNDLE, KEY_COMPONENTS, KEY_ESP32, KEY_EXCLUDE_COMPONENTS, @@ -66,6 +73,7 @@ from .const import ( # noqa KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, KEY_REPO, @@ -78,15 +86,18 @@ from .const import ( # noqa VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, VARIANT_FRIENDLY, VARIANTS, ) # force import gpio to register pin schema -from .gpio import esp32_pin_to_code # noqa +from .gpio import esp32_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] @@ -100,7 +111,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" @@ -108,11 +121,13 @@ CONF_SIGNING_SCHEME = "signing_scheme" CONF_SRAM1_AS_IRAM = "sram1_as_iram" CONF_SUBTYPE = "subtype" CONF_VERIFICATION_KEY = "verification_key" +CONF_VERIFICATION_KEYS = "verification_keys" ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32" ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}" ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs" ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}" +ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32" LOG_LEVELS_IDF = [ "NONE", @@ -129,12 +144,22 @@ ASSERTION_LEVELS = { "SILENT": "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT", } +SIGNING_SCHEME_RSA3072 = "rsa3072" +SIGNING_SCHEME_ECDSA256 = "ecdsa256" +SIGNING_SCHEME_ECDSA_V1 = "ecdsa_v1" + SIGNING_SCHEMES = { - "rsa3072": "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", - "ecdsa256": "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", - "ecdsa_v1": "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", + SIGNING_SCHEME_RSA3072: "CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME", + SIGNING_SCHEME_ECDSA256: "CONFIG_SECURE_SIGNED_APPS_ECDSA_V2_SCHEME", + SIGNING_SCHEME_ECDSA_V1: "CONFIG_SECURE_SIGNED_APPS_ECDSA_SCHEME", } +# A Secure Boot v2 image carries at most three signature blocks, and hardware +# secure boot exposes three eFuse key slots. The trusted-key list isn't bound by +# the per-image limit (an incoming image need only match one trusted key), but +# cap it at three to mirror those hardware limits. +SIGNED_OTA_MAX_KEYS = 3 + # Chip variants that only support one V2 signing scheme. # Based on SOC_SECURE_BOOT_V2_RSA / SOC_SECURE_BOOT_V2_ECC in soc_caps.h. # Variants not listed in either set support both RSA and ECDSA V2 @@ -157,6 +182,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", @@ -167,34 +206,56 @@ COMPILER_OPTIMIZATIONS = { # ESP-IDF components excluded by default to reduce compile time. # Components can be re-enabled by calling include_builtin_idf_component() in to_code(). # -# Cannot be excluded (dependencies of required components): -# - "console": espressif/mdns unconditionally depends on it -# - "sdmmc": driver -> esp_driver_sdmmc -> sdmmc dependency chain +# Note: excluding a component only removes it from the initial build set. +# ESP-IDF's requirement expansion adds an excluded component back when any +# component still in the build REQUIRES it (e.g. espressif/mdns pulls +# "console" back in, esp_http_client pulls "tcp_transport" back in), so +# exclusions here are safe for such components and simply become no-ops in +# builds that need them. DEFAULT_EXCLUDED_IDF_COMPONENTS = ( + "app_trace", # CPU trace/SystemView support - unused by ESPHome "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing + "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers + "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf "esp_adc", # ADC driver - only needed by adc component + "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back "esp_driver_dac", # DAC driver - only needed by esp32_dac component + "esp_driver_gptimer", # General purpose timer - re-included by ac_dimmer, opentherm, Arduino BLE libs + "esp_driver_i2c", # I2C driver - re-included by i2c; esp32-camera pulls it back itself "esp_driver_i2s", # I2S driver - only needed by i2s_audio component + "esp_driver_ledc", # LEDC PWM driver - re-included by ledc; esp32-camera pulls it back itself "esp_driver_mcpwm", # MCPWM driver - ESPHome doesn't use motor control PWM "esp_driver_pcnt", # PCNT driver - only needed by pulse_counter, hlw8012 components "esp_driver_rmt", # RMT driver - only needed by remote_transmitter/receiver, neopixelbus + "esp_driver_sdio", # SDIO device-mode driver - unused by ESPHome + "esp_driver_sdm", # Sigma-delta modulation driver - unused by ESPHome + "esp_driver_sdmmc", # SD/MMC host driver - unused by ESPHome + "esp_driver_sdspi", # SD-over-SPI driver - unused by ESPHome "esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch "esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component "esp_eth", # Ethernet driver - only needed by ethernet component + "esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back "esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality "esp_http_client", # HTTP client - only needed by http_request component + "esp_http_server", # HTTP server - re-included by web_server_idf, esp32_camera_web_server "esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation "esp_https_server", # HTTPS server - ESPHome has its own web server "esp_lcd", # LCD controller drivers - only needed by display component "esp_local_ctrl", # Local control over HTTPS/BLE - ESPHome has native API "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "json", # cJSON library - ESPHome uses ArduinoJson instead "mqtt", # ESP-IDF MQTT library - ESPHome has its own MQTT implementation + "nvs_sec_provider", # NVS encryption key provider - re-included when CONFIG_NVS_ENCRYPTION is set "openthread", # Thread protocol - only needed by openthread component "perfmon", # Xtensa performance monitor - ESPHome has its own debug component + "protobuf-c", # Protobuf runtime - only used by provisioning components (also excluded) "protocomm", # Protocol communication for provisioning - unused by ESPHome + "rt", # POSIX realtime extensions - unused by ESPHome + "sdmmc", # SD/MMC protocol layer - only used by SD drivers and fatfs (also excluded) "spiffs", # SPIFFS filesystem - ESPHome doesn't use filesystem storage (IDF only) + "tcp_transport", # Transport layer - esp_http_client/mqtt pull it back when re-included "ulp", # ULP coprocessor - not currently used by any ESPHome component "unity", # Unit testing framework - ESPHome doesn't use IDF's testing "wear_levelling", # Flash wear levelling for fatfs - unused since fatfs unused @@ -285,6 +346,10 @@ ARDUINO_LIBRARY_IDF_COMPONENTS: dict[str, tuple[str, ...]] = { "Zigbee": ("espressif__esp-zigbee-lib", "espressif__esp-zboss-lib"), } +# Arduino libraries whose sources reference esp_crt_bundle_attach without a +# CONFIG_MBEDTLS_CERTIFICATE_BUNDLE guard, so enabling them needs the bundle. +ARDUINO_LIBRARIES_NEEDING_CERT_BUNDLE = frozenset({"NetworkClientSecure"}) + # Arduino library to Arduino library dependencies # When enabling one library, also enable its dependencies # Kconfig "select" statements don't work with CONFIG_ARDUINO_SELECTIVE_COMPILATION @@ -402,9 +467,12 @@ CPU_FREQUENCIES = { VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), + VARIANT_ESP32H4: get_cpu_frequencies(48, 64, 96), + VARIANT_ESP32H21: get_cpu_frequencies(48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), + VARIANT_ESP32S31: get_cpu_frequencies(240, 320), } # Make sure not missed here if a new variant added. @@ -463,21 +531,20 @@ def set_core_data(config): framework_ver = cv.Version.parse(config[CONF_FRAMEWORK][CONF_VERSION]) CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] = framework_ver - # Store the underlying IDF version for framework-agnostic checks + # Store the underlying IDF version for framework-agnostic checks. if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = framework_ver - elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is not None: - if CORE.using_toolchain_esp_idf: - # Official ESP-IDF frameworks don't use extra - idf_ver = cv.Version(idf_ver.major, idf_ver.minor, idf_ver.patch) - CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver - else: + idf_ver = framework_ver + elif (idf_ver := ARDUINO_IDF_VERSION_LOOKUP.get(framework_ver)) is None: raise cv.Invalid( f"Arduino version {framework_ver} has no known ESP-IDF version mapping. " "Please update ARDUINO_IDF_VERSION_LOOKUP.", path=[CONF_FRAMEWORK, CONF_VERSION], ) + # The esp-idf toolchain doesn't use pioarduino's packaging revision; PIO does. + if CORE.using_toolchain_esp_idf: + idf_ver = _strip_pioarduino_revision(idf_ver) + CORE.data[KEY_ESP32][KEY_IDF_VERSION] = idf_ver CORE.data[KEY_ESP32][KEY_BOARD] = config[CONF_BOARD] CORE.data[KEY_ESP32][KEY_FLASH_SIZE] = config[CONF_FLASH_SIZE] CORE.data[KEY_ESP32][KEY_VARIANT] = variant @@ -490,6 +557,32 @@ def get_esp32_variant(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT] +def variant_filtered_enum( + by_variant: dict[str, Iterable[Any]], **kwargs: Any +) -> Callable[[Any], Any]: + """Build a ``one_of`` validator whose valid set depends on the active variant. + + ``by_variant`` maps each ESP32 variant constant to the iterable of values that + are valid on that variant. At validation time the value is checked against the + set allowed for the current target variant. For schema extraction the inverted + ``{value: [variants, ...]}`` map is returned instead, so the language-schema + dump can tag every option with the variants that accept it and frontends can + filter to the user's selected variant. + """ + by_value: dict[str, list[str]] = {} + for variant, values in by_variant.items(): + for value in values: + by_value.setdefault(str(value), []).append(variant) + + @schema_extractor("variant_enum") + def validator(value: Any) -> Any: + if value is SCHEMA_EXTRACT: + return by_value + return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value) + + return validator + + def get_board(core_obj=None): return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD] @@ -497,16 +590,17 @@ 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. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Factory format (Previously Modern)", @@ -557,11 +651,82 @@ class RawSdkconfigValue: SdkconfigValueType = bool | int | HexInt | str | RawSdkconfigValue +def is_idf_sdkconfig_option_enabled(name: str) -> bool: + """Return True when a bool sdkconfig option resolves to ``y``. + + Handles both the ``True`` a component sets and the raw ``y`` a user sets + in ``sdkconfig_options``. + """ + value = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name) + return value is not None and _format_sdkconfig_val(value) == "y" + + +def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None: + """Set an sdkconfig option unless it is already set. + + For the FINAL priority reconcile jobs: they run after every to_code, + including the user's sdkconfig_options, and must not override an + existing value. + """ + if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: + add_idf_sdkconfig_option(name, value) + + def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): """Set an esp-idf sdkconfig value.""" 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 + 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() -> None: + """Request the Bluetooth controller.""" + net = _network_sdkconfig() + net.bluetooth = 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, @@ -588,6 +753,18 @@ def add_idf_component( } +def get_managed_component_require_names() -> list[str]: + """Return sorted IDF require names for components added via + ``add_idf_component`` (``owner/name`` -> ``owner__name``). + + The build_gen layer (``build_gen.espidf.get_project_cmakelists``) + feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so + converted PIO libraries can REQUIRE them by name at configure time. + """ + components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {}) + return sorted(name.replace("/", "__") for name in components_registry) + + def exclude_builtin_idf_component(name: str) -> None: """Exclude an ESP-IDF component from the build. @@ -610,6 +787,17 @@ def include_builtin_idf_component(name: str) -> None: CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS].discard(name) +def get_excluded_builtin_components() -> list[str]: + """Return the sorted built-in IDF components excluded from the build. + + The set reaches both build writers as the ``EXCLUDE_COMPONENTS`` CMake + arg (registered via ``cg.add_cmake_arg`` at FINAL priority); the native + ESP-IDF writer also reads it directly to filter the built-in component + list. + """ + return sorted(CORE.data.get(KEY_ESP32, {}).get(KEY_EXCLUDE_COMPONENTS, ())) + + def _enable_arduino_library(name: str) -> None: """Enable an Arduino library that is disabled by default. @@ -628,6 +816,10 @@ def _enable_arduino_library(name: str) -> None: # Also enable any required IDF components for idf_component in ARDUINO_LIBRARY_IDF_COMPONENTS.get(name, ()): include_builtin_idf_component(idf_component) + if not ARDUINO_LIBRARIES_NEEDING_CERT_BUNDLE.isdisjoint( + {name, *ARDUINO_LIBRARY_DEPENDENCIES.get(name, ())} + ): + require_certificate_bundle() def add_extra_script(stage: str, filename: str, path: Path): @@ -697,11 +889,17 @@ def _is_framework_url(source: str) -> bool: # The default/recommended arduino framework version # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(3, 3, 8), - "latest": cv.Version(3, 3, 8), - "dev": cv.Version(3, 3, 8), + "recommended": cv.Version(3, 3, 11), + "latest": cv.Version(3, 3, 11), + "dev": cv.Version(3, 3, 11), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version( + 4, 0, 0, "alpha1" + ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", + cv.Version(3, 3, 11): cv.Version(55, 3, 311), + cv.Version(3, 3, 10): cv.Version(55, 3, 39), + cv.Version(3, 3, 9): cv.Version(55, 3, 39), cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"), cv.Version(3, 3, 7): cv.Version(55, 3, 37), cv.Version(3, 3, 6): cv.Version(55, 3, 36), @@ -722,6 +920,10 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # These versions correspond to pioarduino/esp-idf releases # See: https://github.com/pioarduino/esp-idf/releases ARDUINO_IDF_VERSION_LOOKUP = { + cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1), + cv.Version(3, 3, 11): cv.Version(5, 5, 5), + cv.Version(3, 3, 10): cv.Version(5, 5, 5), + cv.Version(3, 3, 9): cv.Version(5, 5, 4), cv.Version(3, 3, 8): cv.Version(5, 5, 4), cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"), cv.Version(3, 3, 6): cv.Version(5, 5, 2), @@ -742,9 +944,9 @@ ARDUINO_IDF_VERSION_LOOKUP = { # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { - "recommended": cv.Version(5, 5, 4), - "latest": cv.Version(5, 5, 4), - "dev": cv.Version(5, 5, 4), + "recommended": cv.Version(5, 5, 5), + "latest": cv.Version(5, 5, 5), + "dev": cv.Version(5, 5, 5), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { @@ -754,7 +956,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 4): cv.Version(55, 3, 38, "1"), + cv.Version(5, 5, 5): cv.Version(55, 3, 311), + cv.Version(5, 5, 4): cv.Version(55, 3, 39), cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37), cv.Version(5, 5, 3): cv.Version(55, 3, 37), cv.Version(5, 5, 2): cv.Version(55, 3, 37), @@ -774,25 +977,21 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 38, "1"), - "latest": cv.Version(55, 3, 38, "1"), + "recommended": cv.Version(55, 3, 311), + "latest": cv.Version(55, 3, 311), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } -def _check_pio_versions(config): - config = config.copy() - value = config[CONF_FRAMEWORK] +def _resolve_framework_version(value: ConfigType) -> cv.Version: + """Resolve a named or raw framework version and validate the minimum. + Normalises value[CONF_VERSION] to its string form and returns the parsed + cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain- + specific concerns (source defaults, platform_version) live in the per- + toolchain functions. + """ if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: - if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: - raise cv.Invalid( - "Version needs to be explicitly set when a custom source or platform_version is used." - ) - - platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]] - value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if value[CONF_TYPE] == FRAMEWORK_ARDUINO: version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]] else: @@ -805,7 +1004,48 @@ def _check_pio_versions(config): if value[CONF_TYPE] == FRAMEWORK_ARDUINO: if version < cv.Version(3, 0, 0): raise cv.Invalid("Only Arduino 3.0+ is supported.") - recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"] + else: + if version < cv.Version(5, 0, 0): + raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") + recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] + + if version != recommended: + _LOGGER.warning( + "The selected framework version is not the recommended one. " + "If there are connectivity or build issues please remove the manual version." + ) + + return version + + +def _strip_pioarduino_revision(ver: cv.Version) -> cv.Version: + """Drop a numeric 'extra' (pioarduino packaging revision, e.g. "5.5.3-1"). + + Alphanumeric prerelease extras (e.g. "6.0.0-rc1") are kept. + """ + if ver.extra.isdigit(): + return cv.Version(ver.major, ver.minor, ver.patch) + return ver + + +def _check_pio_versions(config: ConfigType) -> ConfigType: + config = config.copy() + value = config[CONF_FRAMEWORK] + + is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP + if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value): + raise cv.Invalid( + "Version needs to be explicitly set when a custom source or platform_version is used." + ) + if is_named_version: + value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version( + str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]) + ) + + version = _resolve_framework_version(value) + + if value[CONF_TYPE] == FRAMEWORK_ARDUINO: platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, _format_framework_arduino_version(version) @@ -813,9 +1053,6 @@ def _check_pio_versions(config): if _is_framework_url(value[CONF_SOURCE]): value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}" else: - if version < cv.Version(5, 0, 0): - raise cv.Invalid("Only ESP-IDF 5.0+ is supported.") - recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"] platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version) value[CONF_SOURCE] = value.get( CONF_SOURCE, @@ -831,12 +1068,6 @@ def _check_pio_versions(config): ) value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup)) - if version != recommended_version: - _LOGGER.warning( - "The selected framework version is not the recommended one. " - "If there are connectivity or build issues please remove the manual version." - ) - if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version( str(PLATFORM_VERSION_LOOKUP["recommended"]) ): @@ -848,32 +1079,40 @@ def _check_pio_versions(config): return config -def _check_esp_idf_versions(config): - config = _check_pio_versions(config) +def _check_esp_idf_versions(config: ConfigType) -> ConfigType: + config = config.copy() value = config[CONF_FRAMEWORK] - # Remove unwanted keys if present - for key in (CONF_SOURCE, CONF_PLATFORM_VERSION): - value.pop(key, None) + # platform_version is a PlatformIO concept; drop it if a user carried it + # over from a PIO-style config. CONF_SOURCE, on the other hand, is kept: + # it lets a user override the framework tarball URL under the esp-idf + # toolchain (the espidf framework downloader consults it). + value.pop(CONF_PLATFORM_VERSION, None) - # Official ESP-IDF frameworks don't use extra - version = cv.Version.parse(value[CONF_VERSION]) - version = cv.Version(version.major, version.minor, version.patch) + version = _resolve_framework_version(value) - value[CONF_VERSION] = str(version) + if CONF_SOURCE in value: + _LOGGER.warning( + "A custom framework source is set. " + "If there are connectivity or build issues please remove the manual source." + ) + + # esp-idf framework only: drop pioarduino's packaging revision (config + download). + # Arduino keeps its extra (it's the arduino-esp32 release tag / lookup key). + if value[CONF_TYPE] == FRAMEWORK_ESP_IDF: + value[CONF_VERSION] = str(_strip_pioarduino_revision(version)) return config -def _validate_toolchain(value) -> Toolchain: - return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) +# Runs before _detect_variant so downstream validators can rely on +# CORE.toolchain instead of re-resolving it from the config dict. +_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF) -def _check_versions(config): - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.PLATFORMIO) - +def _check_versions(config: ConfigType) -> ConfigType: if CORE.using_toolchain_esp_idf: return _check_esp_idf_versions(config) return _check_pio_versions(config) @@ -890,30 +1129,52 @@ def _parse_pio_platform_version(value): return value +def _normalize_p4_engineering_sample(value: ConfigType) -> bool: + """Fill in CONF_ENGINEERING_SAMPLE when unset, warning that production + silicon (rev3) is assumed. Returns the normalized flag.""" + if (engineering_sample := value.get(CONF_ENGINEERING_SAMPLE)) is None: + _LOGGER.warning( + "Defaulting to ESP32-P4 production silicon (rev3).\n" + "If you have an early engineering sample (pre-rev3), add this to your config:\n" + "\n" + " esp32:\n" + " engineering_sample: true\n" + "\n" + "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" + "Engineering samples will show a revision below v3.0.\n" + "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." + ) + engineering_sample = False + value[CONF_ENGINEERING_SAMPLE] = engineering_sample + return engineering_sample + + def _detect_variant(value): board = value.get(CONF_BOARD) variant = value.get(CONF_VARIANT) if variant and board is None: # If variant is set, we can derive the board from it - # variant has already been validated against the known set + # variant has already been validated against the known set. + # PlatformIO needs a real board name to find its board file; the + # ESP-IDF toolchain only uses CONF_BOARD as the informational + # ESPHOME_BOARD string, so synthesize one from the friendly variant + # name rather than carrying a PIO board name through the IDF build. + if CORE.using_toolchain_esp_idf: + value = value.copy() + if variant == VARIANT_ESP32P4: + _normalize_p4_engineering_sample(value) + value[CONF_BOARD] = VARIANT_FRIENDLY[variant].lower() + return value + if variant not in STANDARD_BOARDS: + raise cv.Invalid( + f"No default board is known for {variant}. " + f"Please specify the `board:` option explicitly.", + path=[CONF_VARIANT], + ) value = value.copy() value[CONF_BOARD] = STANDARD_BOARDS[variant] - if variant == VARIANT_ESP32P4: - engineering_sample = value.get(CONF_ENGINEERING_SAMPLE) - if engineering_sample is None: - _LOGGER.warning( - "No board specified for ESP32-P4. Defaulting to production silicon (rev3).\n" - "If you have an early engineering sample (pre-rev3), add this to your config:\n" - "\n" - " esp32:\n" - " engineering_sample: true\n" - "\n" - "To check your chip revision, look for 'chip revision: vX.Y' in the boot log.\n" - "Engineering samples will show a revision below v3.0.\n" - "The 'debug:' component also reports the revision (e.g. Revision: 100 = v1.0, 300 = v3.0)." - ) - elif engineering_sample: - value[CONF_BOARD] = "esp32-p4-evboard" + if variant == VARIANT_ESP32P4 and _normalize_p4_engineering_sample(value): + value[CONF_BOARD] = "esp32-p4-evboard" elif board in BOARDS: variant = variant or BOARDS[board][KEY_VARIANT] if variant != BOARDS[board][KEY_VARIANT]: @@ -923,6 +1184,14 @@ def _detect_variant(value): ) value = value.copy() value[CONF_VARIANT] = variant + if variant == VARIANT_ESP32P4: + board_is_es = BOARDS[board].get("engineering_sample", False) + engineering_sample = value.setdefault(CONF_ENGINEERING_SAMPLE, board_is_es) + if engineering_sample != board_is_es: + raise cv.Invalid( + f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{board}'", + path=[CONF_ENGINEERING_SAMPLE], + ) elif not variant: raise cv.Invalid( "This board is unknown, if you are sure you want to compile with this board selection, " @@ -934,13 +1203,259 @@ def _detect_variant(value): "This board is unknown; the specified variant '%s' will be used but this may not work as expected.", variant, ) + if variant == VARIANT_ESP32P4: + value = value.copy() + _normalize_p4_engineering_sample(value) return value -def final_validate(config): +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 + + +def _sbv2_rsa_key_digest(path: Path) -> bytes: + """SHA-256 of a public key's Secure Boot v2 signature-block key region. + + This hashes the 776-byte {n, e, rinv, m'} region exactly as the ROM lays it + out -- i.e. the value the device computes per signature block and the one + ``espsecure digest-sbv2-public-key`` prints, not a hash of the DER key. + """ + import hashlib + import struct + + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + load_pem_public_key, + ) + + data = path.read_bytes() + try: + if b"PUBLIC KEY" in data: + public_key = load_pem_public_key(data) + else: + # verification_keys only needs the public half; warn so the private + # key doesn't end up committed alongside the config. + _LOGGER.warning( + "'%s' is a private key, but '%s' needs only the public key. Use a " + "public-key PEM or the 64-hex digest (espsecure " + "digest-sbv2-public-key) so the private key stays out of your config.", + path, + CONF_VERIFICATION_KEYS, + ) + public_key = load_pem_private_key(data, password=None).public_key() + except (ValueError, TypeError, UnsupportedAlgorithm) as err: + raise cv.Invalid(f"Could not load key '{path}': {err}") from err + if not isinstance(public_key, rsa.RSAPublicKey) or public_key.key_size != 3072: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' entries must be RSA-3072 keys; " + f"'{path}' is not." + ) + numbers = public_key.public_numbers() + n, e = numbers.n, numbers.e + m = (-pow(n, -1, 1 << 32)) & 0xFFFFFFFF + rinv = (1 << (public_key.key_size * 2)) % n + blob = struct.pack( + "<384sI384sI", + n.to_bytes(384, "big")[::-1], + e, + rinv.to_bytes(384, "big")[::-1], + m, + ) + return hashlib.sha256(blob).digest() + + +def _validate_trusted_key(value: Any) -> str: + """Normalize a trusted key to its 64-hex-char signature-block digest. + + Accepts either the digest directly (so CI can inject it without shipping a + key file) or a PEM key file whose digest is computed here. Typed ``Any`` + because YAML hands validators the parsed value -- e.g. an unquoted ``0x...`` + digest arrives as an int, which the guard below rejects with advice to quote. + """ + # An unquoted 0x... or all-digit digest is parsed by YAML as an int before it + # reaches here, so it never looks like a string digest -- reject it clearly + # rather than letting it fall through to cv.file_ as a bogus path. + if not isinstance(value, str): + raise cv.Invalid( + f"Expected a key file path or a 64-character hex digest, got {value!r}. " + f"Quote the digest so YAML keeps it as text (an unquoted '0x...' or " + f"all-digit value is parsed as a number)." + ) + stripped = value.strip() + if re.fullmatch(r"[0-9A-Fa-f]{64}", stripped): + return stripped.lower() + # An all-hex value that isn't exactly 64 chars is a mangled digest, not a + # path: a truncated or 0x-prefixed CI variable would otherwise fall through + # and fail as "file not found", pointing at the wrong problem. + if re.fullmatch(r"(?:0x)?[0-9A-Fa-f]+", stripped): + raise cv.Invalid( + f"'{stripped}' looks like a key digest but must be exactly 64 hex " + f"characters (a SHA-256, no '0x' prefix); check for truncation." + ) + return _sbv2_rsa_key_digest(cv.file_(value)).hex() + + +_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SIGNING_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEY): cv.file_, + cv.Optional(CONF_VERIFICATION_KEYS): cv.All( + cv.ensure_list(_validate_trusted_key), + cv.Length(min=1, max=SIGNED_OTA_MAX_KEYS), + ), + cv.Optional(CONF_SIGNING_SCHEME, default=SIGNING_SCHEME_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. + + For external RSA (rsa3072, no signing key), an optional 'verification_keys' + list names the keys the running app trusts. ESPHome then verifies OTA + signatures against that compiled-in set instead of IDF's single-block + check, which enables key rotation and multi-provider backup keys. + """ + has_signing_key = CONF_SIGNING_KEY in config + has_verification_key = CONF_VERIFICATION_KEY in config + has_verification_keys = CONF_VERIFICATION_KEYS 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 has_verification_keys: + if scheme != SIGNING_SCHEME_RSA3072: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' is only used with signing scheme " + f"'rsa3072' (externally-signed RSA images). With '{scheme}' the " + f"public key travels in each image's signature block.", + path=[CONF_VERIFICATION_KEYS], + ) + if has_signing_key: + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' verifies externally-signed images " + f"and cannot be combined with '{CONF_SIGNING_KEY}' (which signs " + f"during the build). Provide one or the other.", + path=[CONF_VERIFICATION_KEYS], + ) + if has_verification_key: + raise cv.Invalid( + f"Provide at most one of '{CONF_VERIFICATION_KEY}' and " + f"'{CONF_VERIFICATION_KEYS}', not both.", + path=[CONF_VERIFICATION_KEYS], + ) + keys = config[CONF_VERIFICATION_KEYS] + if len(set(keys)) != len(keys): + raise cv.Invalid( + f"'{CONF_VERIFICATION_KEYS}' entries must be unique (duplicate " + f"keys add nothing and waste a trusted-set slot).", + path=[CONF_VERIFICATION_KEYS], + ) + if scheme == SIGNING_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) -> None: # Imported locally to avoid circular import issues from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + from .gpio import final_validate_pins + + # Remove before 2027.2.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for ESP32 is deprecated and will be removed " + "in ESPHome 2027.2.0. Please use 'toolchain: esp-idf' instead." + ) + errs = [] conf_fw = config[CONF_FRAMEWORK] advanced = conf_fw[CONF_ADVANCED] @@ -994,20 +1509,6 @@ def final_validate(config): path=[CONF_ENGINEERING_SAMPLE], ) ) - if ( - config[CONF_VARIANT] == VARIANT_ESP32P4 - and config.get(CONF_ENGINEERING_SAMPLE) is not None - ): - board_is_es = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False - ) - if config[CONF_ENGINEERING_SAMPLE] != board_is_es: - errs.append( - cv.Invalid( - f"'{CONF_ENGINEERING_SAMPLE}' does not match board '{config[CONF_BOARD]}'", - path=[CONF_ENGINEERING_SAMPLE], - ) - ) if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] not in {VARIANT_ESP32S3, VARIANT_ESP32P4}: errs.append( @@ -1024,6 +1525,8 @@ def final_validate(config): ) ) + final_validate_pins(full_config) + if ( config[CONF_FLASH_SIZE] == "32MB" and "ota" in full_config @@ -1061,7 +1564,10 @@ def final_validate(config): ] # V1 ECDSA is only available on the original ESP32 - if scheme == "ecdsa_v1" and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS: + if ( + scheme == SIGNING_SCHEME_ECDSA_V1 + and variant not in SIGNED_OTA_V1_ECDSA_VARIANTS + ): errs.append( cv.Invalid( f"Signing scheme 'ecdsa_v1' is only supported on " @@ -1074,7 +1580,9 @@ def final_validate(config): # On ESP32, V2 RSA requires minimum_chip_revision >= 3.0 # Note: string comparison works here because cv.one_of constrains # min_rev to known ESP32_CHIP_REVISIONS values ("0.0".."3.1"). - if scheme == "rsa3072" and (min_rev is None or min_rev < "3.0"): + if scheme == SIGNING_SCHEME_RSA3072 and ( + min_rev is None or min_rev < "3.0" + ): errs.append( cv.Invalid( f"Signing scheme 'rsa3072' on {VARIANT_FRIENDLY[variant]} " @@ -1085,7 +1593,7 @@ def final_validate(config): ) ) # ESP32 does not support V2 ECDSA (no SOC_SECURE_BOOT_V2_ECC) - elif scheme == "ecdsa256": + elif scheme == SIGNING_SCHEME_ECDSA256: errs.append( cv.Invalid( f"Signing scheme 'ecdsa256' is not supported on " @@ -1095,7 +1603,11 @@ def final_validate(config): ) ) # V1 on rev 3.0+ -- suggest V2 RSA for stronger security - elif scheme == "ecdsa_v1" and min_rev is not None and min_rev >= "3.0": + elif ( + scheme == SIGNING_SCHEME_ECDSA_V1 + and min_rev is not None + and min_rev >= "3.0" + ): _LOGGER.info( "Using Secure Boot V1 ECDSA on %s rev %s. " "Consider using 'rsa3072' (Secure Boot V2 RSA) for " @@ -1106,8 +1618,14 @@ def final_validate(config): else: # Non-ESP32 variants: check V2 scheme-variant compatibility scheme_variant_conflicts = { - "ecdsa256": (SIGNED_OTA_V2_RSA_ONLY_VARIANTS, "rsa3072"), - "rsa3072": (SIGNED_OTA_V2_ECC_ONLY_VARIANTS, "ecdsa256"), + SIGNING_SCHEME_ECDSA256: ( + SIGNED_OTA_V2_RSA_ONLY_VARIANTS, + SIGNING_SCHEME_RSA3072, + ), + SIGNING_SCHEME_RSA3072: ( + SIGNED_OTA_V2_ECC_ONLY_VARIANTS, + SIGNING_SCHEME_ECDSA256, + ), } if ( conflict := scheme_variant_conflicts.get(scheme) @@ -1134,15 +1652,44 @@ 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) - return config - CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" @@ -1182,6 +1729,7 @@ KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" +KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" def require_vfs_select() -> None: @@ -1211,6 +1759,16 @@ def require_vfs_termios() -> None: CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True +def require_certificate_bundle() -> None: + """Enable the mbedTLS root certificate bundle for this build. + + The bundle is off by default; components that verify TLS server + certificates (http_request, audio streaming) call this so the bundle is + compiled and gen_crt_bundle runs only when something uses it. + """ + CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True + + def require_full_certificate_bundle() -> None: """Request the full certificate bundle instead of the common-CAs-only bundle. @@ -1220,6 +1778,7 @@ def require_full_certificate_bundle() -> None: Call this from components that need to connect to services using uncommon CAs. """ + require_certificate_bundle() CORE.data[KEY_ESP32][KEY_FULL_CERT_BUNDLE] = True @@ -1290,6 +1849,18 @@ def require_adc_oneshot_iram() -> None: CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True +def require_libc_picolibc_newlib_compat() -> None: + """Keep CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY enabled on IDF 6.0+. + + Call this from components that link against precompiled Newlib binaries + referencing types/symbols the shim provides (e.g. zigbee). No-op on + IDF < 6.0.0. + """ + if idf_version() < cv.Version(6, 0, 0): + return + CORE.data[KEY_ESP32][KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED] = True + + def _parse_idf_component(value: str) -> ConfigType: """Parse IDF component shorthand syntax like 'owner/component^version'""" # Match operator followed by version-like string (digit or *) @@ -1307,16 +1878,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 @@ -1362,17 +1937,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 @@ -1394,7 +1972,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), @@ -1413,65 +1993,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 @@ -1522,8 +2049,14 @@ FLASH_SIZES = [ ] CONF_FLASH_SIZE = "flash_size" +CONF_FLASH_MODE = "flash_mode" +CONF_FLASH_FREQUENCY = "flash_frequency" CONF_CPU_FREQUENCY = "cpu_frequency" CONF_PARTITIONS = "partitions" +FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"] +FLASH_FREQUENCIES = [ + f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16) +] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -1537,7 +2070,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( *FLASH_SIZES, upper=True ), - cv.Optional(CONF_PARTITIONS): cv.Any( + cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True), + cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of( + *FLASH_FREQUENCIES, upper=True + ), + cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any( cv.file_, cv.ensure_list( cv.All( @@ -1561,13 +2098,16 @@ 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)), ), } ), + _resolve_toolchain, _detect_variant, _set_default_framework, _check_versions, @@ -1646,17 +2186,20 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +def register_exclude_components_cmake_arg() -> None: + """Register the current exclusion set as the EXCLUDE_COMPONENTS cmake arg.""" + if excluded := get_excluded_builtin_components(): + cg.add_cmake_arg("EXCLUDE_COMPONENTS", ";".join(excluded)) + + @coroutine_with_priority(CoroPriority.FINAL) async def _write_exclude_components() -> None: """Write EXCLUDE_COMPONENTS cmake arg after all components have registered exclusions.""" - if KEY_ESP32 not in CORE.data: - return - excluded = CORE.data[KEY_ESP32].get(KEY_EXCLUDE_COMPONENTS) - if excluded: - exclude_list = ";".join(sorted(excluded)) - cg.add_platformio_option( - "board_build.cmake_extra_args", f"-DEXCLUDE_COMPONENTS={exclude_list}" - ) + # NVS encryption needs nvs_sec_provider however it was enabled: the + # nvs_encryption option, raw sdkconfig_options or another component. + if is_idf_sdkconfig_option_enabled("CONFIG_NVS_ENCRYPTION"): + include_builtin_idf_component("nvs_sec_provider") + register_exclude_components_cmake_arg() @coroutine_with_priority(CoroPriority.FINAL) @@ -1694,6 +2237,100 @@ async def _write_arduino_libraries_sdkconfig() -> None: add_idf_sdkconfig_option(f"CONFIG_ARDUINO_SELECTIVE_{lib}", lib in enabled_libs) +@coroutine_with_priority(CoroPriority.FINAL) +async def _set_libc_picolibc_newlib_compat() -> None: + """Apply the PicolibC Newlib compatibility shim option on IDF 6.0+. + + IDF 6.0 switched from Newlib to PicolibC; the shim is disabled by default. + Runs at FINAL priority so every require_libc_picolibc_newlib_compat() call + (default priority) is seen before the option is written. A user-supplied + sdkconfig_options value takes precedence. + """ + if idf_version() < cv.Version(6, 0, 0): + return + option = "CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY" + if option in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: + return + add_idf_sdkconfig_option( + option, + CORE.data[KEY_ESP32].get(KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED, False), + ) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_certificate_bundle_sdkconfig() -> None: + """Enable the mbedTLS certificate bundle only when something asked for it. + + Runs at FINAL priority so every require_certificate_bundle() call has + happened. Without a request the bundle is disabled, which skips + esp_crt_bundle.c, the gen_crt_bundle step and the x509_crt_bundle.S embed. + A user-supplied sdkconfig_options value takes precedence. + """ + data = CORE.data[KEY_ESP32] + enabled = data.get(KEY_CERT_BUNDLE, False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", enabled) + if not enabled: + return + # Use CMN (common CAs) bundle by default to save ~51KB flash + # CMN covers CAs with >1% market share (~99% of websites) + # Components needing uncommon CAs can call require_full_certificate_bundle() + use_full_bundle = data.get(KEY_FULL_CERT_BUNDLE, False) + set_idf_sdkconfig_default( + "CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL", use_full_bundle + ) + if not use_full_bundle: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) + + +@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()) + is_arduino = CORE.using_arduino + + # Bluetooth: only ever enable when requested. The IDF default is off. + # According to the IDF docs, only one of 4.2 or 5.0 should be enabled. + if net.bluetooth: + set_idf_sdkconfig_default("CONFIG_BT_ENABLED", True) + set_idf_sdkconfig_default("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + set_idf_sdkconfig_default("CONFIG_BT_BLE_50_FEATURES_SUPPORTED", False) + + # 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_idf_sdkconfig_default("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_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True) + elif wifi_disabled: + set_idf_sdkconfig_default("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_idf_sdkconfig_default("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_idf_sdkconfig_default("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.""" @@ -1706,6 +2343,82 @@ async def _add_yaml_idf_components(components: list[ConfigType]): ) +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_vfs_fatfs_sdkconfig( + disable_vfs_termios: bool, + disable_vfs_select: bool, + disable_vfs_dir: bool, + disable_fatfs: bool, +) -> None: + """Reconcile VFS/FATFS sdkconfig flags after all require_*() calls; user sdkconfig_options win.""" + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + # USB Serial JTAG VFS needs termios (require_vfs_termios(), e.g. logger). ~1.8KB flash when off. + if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): + set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_TERMIOS", True) + else: + set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_TERMIOS", not disable_vfs_termios) + + # VFS select is only needed for UART/eventfd fds (require_vfs_select(), e.g. openthread); + # sockets use lwip_select() either way. ~2.7KB flash when off. + if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): + set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_SELECT", True) + else: + set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_SELECT", not disable_vfs_select) + + # Directory functions: opendir/readdir/mkdir etc. (require_vfs_dir()). ~0.5KB flash when off. + if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): + set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_DIR", True) + else: + set_idf_sdkconfig_default("CONFIG_VFS_SUPPORT_DIR", not disable_vfs_dir) + + # FATFS (require_fatfs()): LFN + one volume per esp_vfs_fat mount. Defaults only; + # sdkconfig_options override. FATFS_LONG_FILENAMES is a Kconfig choice -- if the user set + # any member, leave the group alone. LFN_HEAP allocates per LFN op; LFN_STACK uses stack. + lfn_keys = ( + "CONFIG_FATFS_LFN_NONE", + "CONFIG_FATFS_LFN_HEAP", + "CONFIG_FATFS_LFN_STACK", + ) + user_picked_lfn = any(k in opts for k in lfn_keys) + if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): + if not user_picked_lfn: + set_idf_sdkconfig_default("CONFIG_FATFS_LFN_NONE", False) + set_idf_sdkconfig_default("CONFIG_FATFS_LFN_HEAP", True) + set_idf_sdkconfig_default("CONFIG_FATFS_MAX_LFN", 255) + set_idf_sdkconfig_default("CONFIG_FATFS_VOLUME_COUNT", 4) + elif disable_fatfs: + if not user_picked_lfn: + set_idf_sdkconfig_default("CONFIG_FATFS_LFN_NONE", True) + # Kconfig range is [1,10]; 0 gets clamped to the default. + set_idf_sdkconfig_default("CONFIG_FATFS_VOLUME_COUNT", 1) + + +@coroutine_with_priority(CoroPriority.FINAL - 1) +async def _finalize_arduino_aware_flags(): + """Build flags that depend on whether arduino-esp32 is linked in. + + Scheduler runs lower priority values later, so ``FINAL - 1`` fires + after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) -- + by then ``KEY_COMPONENTS`` is fully populated. + + - Skip our esp_panic_handler wrap when Arduino is linked; Arduino + wraps the same symbol and the linker errors on the duplicate. + - Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component + case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The + framework=arduino branch already adds it inline in to_code. + """ + arduino_linked = ( + CORE.using_arduino + or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS] + ) + if not arduino_linked: + cg.add_build_flag("-Wl,--wrap=esp_panic_handler") + cg.add_define("USE_ESP32_CRASH_HANDLER") + elif not CORE.using_arduino: + cg.add_build_flag("-DUSE_ARDUINO") + + async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] conf = config[CONF_FRAMEWORK] @@ -1727,6 +2440,12 @@ async def to_code(config): "board_upload.maximum_size", int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, ) + if flash_mode := config.get(CONF_FLASH_MODE): + cg.add_platformio_option("board_build.flash_mode", flash_mode) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + cg.add_platformio_option( + "board_build.f_flash", f"{flash_frequency[:-3]}000000L" + ) if CONF_SOURCE in conf: cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]]) @@ -1753,21 +2472,23 @@ async def to_code(config): Path(__file__).parent / "iram_fix.py.script", ) else: - cg.add_build_flag("-Wno-error=format") - cg.add_build_flag("-Wno-error=maybe-uninitialized") - cg.add_build_flag("-Wno-error=missing-field-initializers") - cg.add_build_flag("-Wno-error=reorder") - cg.add_build_flag("-Wno-error=volatile") + # Demote IDF's blanket -Werror to warnings so third-party libs + # and user lambdas don't need a -Wno-error= per warning. + # The sdkconfig knob disables IDF's rewrite to -Werror=all (which + # can't be globally undone); -Wno-error then handles the demotion. + add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False) + cg.add_build_flag("-Wno-error") + # -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates + cg.add_build_flag("-Wno-missing-field-initializers") cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("USE_NATIVE_64BIT_TIME") + # NVS finds stored preferences by key, so preference key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") - # Arduino already wraps esp_panic_handler for its own backtrace handler, - # so only add our wrap when using ESP-IDF framework to avoid linker conflicts. - if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: - cg.add_build_flag("-Wl,--wrap=esp_panic_handler") - cg.add_define("USE_ESP32_CRASH_HANDLER") + # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. + CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") @@ -1857,36 +2578,33 @@ async def to_code(config): ) add_idf_sdkconfig_option("CONFIG_MBEDTLS_PSK_MODES", True) - add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) cg.add_build_flag("-Wno-nonnull-compare") - # Use CMN (common CAs) bundle by default to save ~51KB flash - # CMN covers CAs with >1% market share (~99% of websites) - # Components needing uncommon CAs can call require_full_certificate_bundle() - use_full_bundle = conf[CONF_ADVANCED].get( - CONF_USE_FULL_CERTIFICATE_BUNDLE, False - ) or CORE.data[KEY_ESP32].get(KEY_FULL_CERT_BUNDLE, False) - add_idf_sdkconfig_option( - "CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL", use_full_bundle - ) - if not use_full_bundle: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) + if conf[CONF_ADVANCED].get(CONF_USE_FULL_CERTIFICATE_BUNDLE, False): + require_full_certificate_bundle() add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True ) + if flash_mode := config.get(CONF_FLASH_MODE): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True + ) + if flash_frequency := config.get(CONF_FLASH_FREQUENCY): + add_idf_sdkconfig_option( + f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True + ) - # ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3 - # from y to n. PlatformIO uses sections.ld.in (for rev <3) or - # sections.rev3.ld.in (for rev >=3) based on board definition. - # Set the sdkconfig option to match the board's chip revision. + # ESP32-P4: pre-v3 and rev3 (v3.0+) silicon are not binary compatible. + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 selects which layout ESP-IDF links; + # validation normalizes CONF_ENGINEERING_SAMPLE from the board when unset. if variant == VARIANT_ESP32P4: - is_eng_sample = BOARDS.get(config[CONF_BOARD], {}).get( - "engineering_sample", False + add_idf_sdkconfig_option( + "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", + config.get(CONF_ENGINEERING_SAMPLE, False), ) - add_idf_sdkconfig_option("CONFIG_ESP32P4_SELECTS_REV_LESS_V3", is_eng_sample) # Set minimum chip revision for ESP32 variant # Setting this to 3.0 or higher reduces flash size by excluding workaround code, @@ -1948,7 +2666,7 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True) # Setup watchdog - add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) + add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) @@ -1979,18 +2697,17 @@ 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]: - add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) + # Kconfig range is [1,63]; 0 gets clamped to the default. + add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1) _configure_lwip_max_sockets(conf) @@ -2021,47 +2738,6 @@ async def to_code(config): if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) - # Disable VFS support for termios (terminal I/O functions) - # USB Serial JTAG VFS functions require termios support. - # Components that need it (e.g., logger when USB_SERIAL_JTAG is supported but not selected - # as the logger output) call require_vfs_termios(). - # Saves approximately 1.8KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_TERMIOS_REQUIRED, False): - # Component requires VFS termios - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_TERMIOS", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] - ) - - # Disable VFS support for select() with file descriptors - # ESPHome only uses select() with sockets via lwip_select(), which still works. - # VFS select is only needed for UART/eventfd file descriptors. - # Components that need it (e.g., openthread) call require_vfs_select(). - # Saves approximately 2.7KB of flash when disabled (default). - if CORE.data.get(KEY_VFS_SELECT_REQUIRED, False): - # Component requires VFS select - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_SELECT", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] - ) - - # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) - # ESPHome doesn't use directory functions on ESP32. - # Components that need it (e.g., storage components) call require_vfs_dir(). - # Saves approximately 0.5KB+ of flash when disabled (default). - if CORE.data.get(KEY_VFS_DIR_REQUIRED, False): - # Component requires VFS directory support - force enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_VFS_SUPPORT_DIR", True) - else: - # No component needs it - allow user to control (default: disabled) - add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] - ) - if use_platformio: cg.add_platformio_option("board_build.partitions", "partitions.csv") if CONF_PARTITIONS in config: @@ -2082,7 +2758,6 @@ async def to_code(config): for key, flag in ASSERTION_LEVELS.items(): add_idf_sdkconfig_option(flag, assertion_level == key) - add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) @@ -2110,12 +2785,83 @@ 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) - add_idf_sdkconfig_option("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", True) scheme = signed_ota[CONF_SIGNING_SCHEME] + # For externally-signed RSA images with a declared 'verification_keys' + # list, ESPHome verifies the OTA signature itself instead of using IDF's + # on-update check. IDF only matches the incoming image's first signature + # block against the running app's first, which blocks key rotation and + # multi-provider backup keys; ESPHome accepts an image signed by any key + # in the compiled-in trusted set. Without 'verification_keys' there is no + # trust anchor, so fall back to IDF's built-in check. + # The build still produces the padded unsigned image (via SECURE_ + # SIGNED_APPS_NO_SECURE_BOOT above); only the on-update check moves. + # SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT defaults to y under + # SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it must be set explicitly: + # False to hand verification to ESPHome, True to keep IDF's check. + # Setting it False also drives the hidden CONFIG_SECURE_SIGNED_APPS to + # n; the 4 KiB padding and reserved signature sector the verifier + # depends on survive only because --secure-pad-v2 keys off + # CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME (set below), not that symbol. + external_rsa = ( + scheme == SIGNING_SCHEME_RSA3072 and CONF_SIGNING_KEY not in signed_ota + ) + verification_keys = signed_ota.get(CONF_VERIFICATION_KEYS) + # verification_keys is accepted only for external RSA (rsa3072 with no + # signing_key), enforced in _validate_signed_ota_keys. Assert the + # post-condition so validator/codegen drift fails the build loudly + # instead of silently dropping the declared trust anchor and downgrading + # to IDF's single-block check. + assert not verification_keys or external_rsa + multi_key = external_rsa and verification_keys + # Turning IDF's on-update check off is global -- it also drops the + # signature check from esp_ota_set_boot_partition() on the partition-table + # path and safe_mode's recovery rollback. Both deliberately select an + # already-installed image (or an MD5-checked partition table), not a + # freshly-downloaded one, so ESPHome's verifier only needs to cover the + # app and bootloader OTA paths, where a new image is actually written. + add_idf_sdkconfig_option( + "CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT", not multi_key + ) + if multi_key: + cg.add_define("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY") + # Compile the trusted key digests in as the immutable trust anchor. + # Each is the SHA-256 of a key's signature-block region; the verifier + # accepts an OTA whose signature block matches one of these. + digests = [bytes.fromhex(k) for k in verification_keys] + # Echo the resolved digests so a stale or mistyped key (which builds + # cleanly but leaves the device updatable only by serial reflash) is + # visible in the build log. + _LOGGER.info( + "Signed OTA verification trusts %d key digest(s): %s", + len(digests), + ", ".join(d.hex() for d in digests), + ) + cg.add_define("OTA_TRUSTED_KEY_COUNT", len(digests)) + cg.add_define( + "OTA_TRUSTED_KEY_DIGESTS", + cg.RawExpression( + "{" + + ",".join( + "{" + ",".join(f"0x{b:02x}" for b in d) + "}" for d in digests + ) + + "}" + ), + ) + for key, flag in SIGNING_SCHEMES.items(): add_idf_sdkconfig_option(flag, scheme == key) @@ -2124,18 +2870,36 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True) add_idf_sdkconfig_option( "CONFIG_SECURE_BOOT_SIGNING_KEY", - str(signed_ota[CONF_SIGNING_KEY].resolve()), + 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", - str(signed_ota[CONF_VERIFICATION_KEY].resolve()), - ) + 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( @@ -2202,17 +2966,24 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # Disable PicolibC Newlib compatibility shim on IDF 6.0+ - # IDF 6.0 switched from Newlib to PicolibC. The shim provides thread-local - # stdin/stdout/stderr and getreent() for code compiled against Newlib. - # ESPHome doesn't link against Newlib-built libraries that use stdio. - # If a component needs it (e.g. precompiled Newlib binaries), re-enable via: - # esp32: - # framework: - # sdkconfig_options: - # CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY: "y" - if idf_version() >= cv.Version(6, 0, 0): - add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", False) + # 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) + + # FINAL priority: runs after every require_certificate_bundle() call + CORE.add_job(_reconcile_certificate_bundle_sdkconfig) + + # FINAL: require_*() calls can come from to_code at or below this priority, so an + # inline read would be iteration-order-dependent; reconcile once after every job ran. + CORE.add_job( + _reconcile_vfs_fatfs_sdkconfig, + advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS], + advanced[CONF_DISABLE_VFS_SUPPORT_SELECT], + advanced[CONF_DISABLE_VFS_SUPPORT_DIR], + advanced[CONF_DISABLE_FATFS], + ) # Disable regi2c control functions in IRAM # Only needed if using analog peripherals (ADC, DAC, etc.) from ISRs while cache is disabled @@ -2229,18 +3000,12 @@ async def to_code(config): ): add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True) - # Disable FATFS support - # Components that need FATFS (SD card, etc.) can call require_fatfs() - if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False): - # Component called require_fatfs() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", False) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2) - elif advanced[CONF_DISABLE_FATFS]: - add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True) - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0) - for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) + # A bundle forced on through sdkconfig_options is a request like any other, + # so it still gets the CMN variant pinned. + if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y": + require_certificate_bundle() # Components from YAML are added in a separate coroutine with FINAL priority # Schedule it to run after all other components @@ -2452,8 +3217,14 @@ def _write_sdkconfig(): ) want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + # Include the resolved framework version as a Kconfig comment so a + # version switch that happens to leave the option set unchanged still + # bumps this file's content -- which is what has_outdated_files() + # uses to decide whether to reconfigure. + framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] contents = ( - "\n".join( + f"# ESPHOME_IDF_VERSION={framework_version}\n" + + "\n".join( f"{name}={_format_sdkconfig_val(value)}" for name, value in sorted(want_opts.items()) ) @@ -2466,14 +3237,6 @@ def _write_sdkconfig(): clean_build(clear_pio_cache=False) -def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]: - dependency: dict[str, str] = {} - name, version, path = generate_idf_component(library) - dependency["override_path"] = str(path) - dependency["version"] = version - return name, dependency - - def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} @@ -2497,7 +3260,12 @@ def _write_idf_component_yml(): stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) - for component_name in components_to_stub: + # Sort so the dict insertion order (and thus the generated + # src/idf_component.yml) is deterministic across runs; otherwise + # the manifest content shuffles every build, write_file_if_changed + # always writes, and ninja keeps triggering CMake re-runs on + # otherwise-cached rebuilds. + for component_name in sorted(components_to_stub): # Create stub directory with minimal CMakeLists.txt stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) @@ -2509,6 +3277,26 @@ def _write_idf_component_yml(): "override_path": str(stub_path), } + # On the PlatformIO toolchain, framework-arduinoespressif32 already + # ships arduino-esp32. Stub the managed component so anything that + # `REQUIRES arduino-esp32` (e.g. third-party FastLED) resolves to a + # CMake target that re-exports the framework's INTERFACE properties + # (INCLUDE_DIRS, public compile options like -DESP32, transitive + # REQUIRES) instead of triggering a duplicate download/rebuild. + if CORE.using_toolchain_platformio: + arduino_stub = stubs_dir / "arduino-esp32" + arduino_stub.mkdir(exist_ok=True) + write_file_if_changed( + arduino_stub / "CMakeLists.txt", + "idf_component_register()\n" + "target_link_libraries(${COMPONENT_LIB} " + f"INTERFACE idf::{ARDUINO_FRAMEWORK_NAME})\n", + ) + dependencies[ARDUINO_ESP32_COMPONENT_NAME] = { + "version": "*", + "override_path": str(arduino_stub), + } + # Remove stubs for components that are now required by enabled libraries for component_name in required_idf_components: stub_path = stubs_dir / _idf_component_stub_name(component_name) @@ -2517,18 +3305,26 @@ def _write_idf_component_yml(): if CORE.using_toolchain_esp_idf: add_idf_component( - name="espressif/arduino-esp32", + name=ARDUINO_ESP32_COMPONENT_NAME, ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) if CORE.using_toolchain_esp_idf: - # Try to convert PlatformIO library to ESP-IDF components - for name, library in CORE.platformio_libraries.items(): + # Convert the PlatformIO libraries to ESP-IDF components as a batch so + # PlatformIO resolves the whole dependency tree at once -- deduplicating + # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c + # and esp_wireguard) to a single version instead of clashing + # override_path entries. + libraries = [ + library + for name, library in CORE.platformio_libraries.items() # Don't process arduino libraries - if name in ARDUINO_DISABLED_LIBRARIES: - continue - dependency_name, dependency = _platformio_library_to_dependency(library) - dependencies[dependency_name] = dependency + if name not in ARDUINO_DISABLED_LIBRARIES + ] + for component in generate_idf_components(libraries): + dependencies[component.get_sanitized_name()] = { + "override_path": str(component.path) + } if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] @@ -2570,30 +3366,78 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - content = requests.get(path, timeout=30).content - CORE.relative_build_path(name).write_bytes(content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): - from esphome.platformio import toolchain + # Convert toolchain-resolution errors (e.g. missing build dir / cmake + # cache) into EsphomeError. The api log processor stops decoding on any + # exception, so this is about the message it reports rather than about + # catching it at all: EsphomeError carries an explanation worth showing + # the user, where a raw OSError repr does not. + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain as idf_toolchain - idedata = toolchain.get_idedata(config) - if not idedata.addr2line_path or not idedata.firmware_elf_path: + try: + addr2line_path = idf_toolchain.get_addr2line_path() + firmware_elf_path = idf_toolchain.get_elf_path() + except (RuntimeError, OSError) as err: + # OSError covers a missing build directory or a cmake that isn't + # on PATH; both surface from the subprocess call, not as RuntimeError. + raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err + if not firmware_elf_path.is_file(): + raise EsphomeError(f"Firmware ELF not found: {firmware_elf_path}") + else: + from esphome.platformio import toolchain + + idedata = toolchain.get_idedata(config) + addr2line_path = idedata.addr2line_path + firmware_elf_path = idedata.firmware_elf_path + if not addr2line_path or not firmware_elf_path: _LOGGER.debug("decode_pc no addr2line") return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return @@ -2611,9 +3455,10 @@ def _parse_register(config, regex, line): STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*") -STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP32_C3_MTVAL_RE = re.compile(r".*MTVAL\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) @@ -2631,9 +3476,10 @@ def process_stacktrace(config, line, backtrace_state): # ESP32 PC/EXCVADDR _parse_register(config, STACKTRACE_ESP32_PC_RE, line) _parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line) - # ESP32-C3 PC/RA + # ESP32-C3 PC/RA/MTVAL _parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line) _parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line) + _parse_register(config, STACKTRACE_ESP32_C3_MTVAL_RE, line) # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) @@ -2656,3 +3502,10 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state + + +# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which +# are instantiated solely by the pin schema codegen (esp32_pin_to_code) +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} +) diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2c73fe7d08..729b0c89ab 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -9,7 +9,6 @@ from .const import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANTS, ) STANDARD_BOARDS = { @@ -25,9 +24,6 @@ STANDARD_BOARDS = { VARIANT_ESP32S3: "esp32-s3-devkitc-1", } -# Make sure not missed here if a new variant added. -assert all(v in STANDARD_BOARDS for v in VARIANTS) - ESP32_BASE_PINS = { "TX": 1, "RX": 3, @@ -1244,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, @@ -1261,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 d0d00723fc..e7d8a66e7a 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -1,9 +1,22 @@ import esphome.codegen as cg -KEY_ESP32 = "esp32" +# Re-exported for the many esp32-side users; defined in esphome.const +# and esphome.espidf so the upload/logs fast path can use them without +# importing this package. +from esphome.const import ( # noqa: F401 # pylint: disable=unused-import + KEY_ESP32, + KEY_FLASH_SIZE, + KEY_IDF_VERSION, + KEY_VARIANT, +) + +# Back compat for external components only; in-tree callers import it +# from esphome.espidf directly. +from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import + variant_to_idf_target, +) + KEY_BOARD = "board" -KEY_FLASH_SIZE = "flash_size" -KEY_VARIANT = "variant" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" KEY_COMPONENTS = "components" KEY_EXCLUDE_COMPONENTS = "exclude_components" @@ -14,8 +27,9 @@ KEY_REFRESH = "refresh" KEY_PATH = "path" KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" +KEY_CERT_BUNDLE = "cert_bundle" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" -KEY_IDF_VERSION = "idf_version" +KEY_NETWORK_SDKCONFIG = "network_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" @@ -24,9 +38,12 @@ VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32C61 = "ESP32C61" VARIANT_ESP32H2 = "ESP32H2" +VARIANT_ESP32H4 = "ESP32H4" +VARIANT_ESP32H21 = "ESP32H21" VARIANT_ESP32P4 = "ESP32P4" VARIANT_ESP32S2 = "ESP32S2" VARIANT_ESP32S3 = "ESP32S3" +VARIANT_ESP32S31 = "ESP32S31" VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32C2, @@ -35,9 +52,12 @@ VARIANTS = [ VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, ] VARIANT_FRIENDLY = { @@ -48,9 +68,13 @@ VARIANT_FRIENDLY = { VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32C61: "ESP32-C61", VARIANT_ESP32H2: "ESP32-H2", + VARIANT_ESP32H4: "ESP32-H4", + VARIANT_ESP32H21: "ESP32-H21", VARIANT_ESP32P4: "ESP32-P4", VARIANT_ESP32S2: "ESP32-S2", VARIANT_ESP32S3: "ESP32-S3", + VARIANT_ESP32S31: "ESP32-S31", } + esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 5249f4a59e..a6916fe739 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -2,13 +2,16 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "preferences.h" #include #include void setup(); // NOLINT(readability-redundant-declaration) -// Weak stub for initArduino - overridden when the Arduino component is present +// Weak stub for initArduino - overridden when the Arduino component is present. +// Name must match the Arduino framework's entry point, so the naming check is suppressed. +// NOLINTNEXTLINE(readability-identifier-naming) extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { @@ -27,6 +30,13 @@ void loop_task(void *pv_params) { } extern "C" void app_main() { + // Apply the custom eFuse MAC (if burned and valid) as the base MAC before any + // interface (Wi-Fi, Ethernet, Bluetooth, 802.15.4) derives its address from it. + // The logger does not exist yet, so only log-free helpers may be used here. + uint8_t mac[MAC_ADDRESS_SIZE]; + if (get_custom_mac_address(mac)) { + set_mac_address(mac); + } initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index ed61b61936..6f65243aaa 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP32_CRASH_HANDLER #include "crash_handler.h" +#include "esphome/core/build_info_data.h" #include "esphome/core/log.h" #include @@ -41,6 +42,7 @@ static inline bool is_return_addr(uint32_t addr) { // Use memcpy for alignment safety — RISC-V C extension means code addresses // are only 2-byte aligned, so addr-4 may not be 4-byte aligned. uint32_t inst; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point memcpy(&inst, (const void *) (addr - 4), sizeof(inst)); // RISC-V instruction encoding: bits [6:0] = opcode, bits [11:7] = rd uint32_t opcode = inst & 0x7f; // Extract 7-bit opcode @@ -51,6 +53,7 @@ static inline bool is_return_addr(uint32_t addr) { // Check for 2-byte compressed c.jalr before this address (C extension). // c.jalr saves to ra implicitly: funct4=1001, rs1!=0, rs2=0, op=10 if (addr >= 2) { + // NOLINTNEXTLINE(performance-no-int-to-ptr) - reading code memory at a raw address is the point uint16_t c_inst = *(uint16_t *) (addr - 2); if ((c_inst & 0xf07f) == 0x9002 && (c_inst & 0x0f80) != 0) return true; @@ -101,6 +104,7 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou out[count++] = frame->ra; } *reg_count = count; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - walking the raw stack by address is the point auto *scan_start = (uint32_t *) frame->sp; for (uint32_t i = 0; i < 64 && count < max; i++) { uint32_t val = scan_start[i]; @@ -119,7 +123,16 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou // Magic is second to validate the data. Remaining fields can change between versions. // Version is uint32_t because it would be padded to 4 bytes anyway before the next // uint32_t field, so we use the full width rather than wasting 3 bytes of padding. -static constexpr uint32_t CRASH_DATA_VERSION = 2; +static constexpr uint32_t CRASH_DATA_VERSION = 4; +#if CONFIG_IDF_TARGET_ARCH_XTENSA +// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's +// cause/vaddr slots were never written (not a real exception frame). +static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +// Synchronous mcause exception codes are small and have no interrupt bit; +// anything else in a non-pseudo record is a stale slot. +static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32; +#endif struct RawCrashData { uint32_t version; uint32_t magic; @@ -129,7 +142,9 @@ struct RawCrashData { uint8_t exception; // panic_exception_t enum (FAULT/ABORT/IWDT/TWDT/DEBUG) uint8_t pseudo_excause; // Whether cause is a pseudo exception (Xtensa SoC-level panic) uint32_t backtrace[MAX_BACKTRACE]; - uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint32_t cause; // Architecture-specific: exccause (Xtensa) or mcause (RISC-V) + uint32_t fault_addr; // Faulting memory address: excvaddr (Xtensa) or mtval (RISC-V) + uint32_t build_time; // ESPHOME_BUILD_TIME of the firmware that captured this record uint8_t crashed_core; #if SOC_CPU_CORES_NUM > 1 static_assert(SOC_CPU_CORES_NUM == 2, "Dual-core logic assumes exactly 2 cores"); @@ -148,6 +163,16 @@ namespace esphome::esp32 { static const char *const TAG = "esp32.crash"; +// RAM copy of the build timestamp. The generated constant lives in flash, +// which the panic handler must not read (cache may be disabled during +// cache-error panics), so the wrapper stamps the record from this mirror +// instead. Filled during C++ dynamic initialization, well before arch_init(); +// ESPHOME_BUILD_TIME itself is constant-initialized, so the read is ordered. +// Unqualified name on purpose: the runtime header declares it in namespace +// esphome, while the static-analysis stub defines it as a macro. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME); + void crash_handler_read_and_clear() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; @@ -182,10 +207,28 @@ void crash_handler_clear() { s_raw_crash_data.magic = 0; } +// Whether the cause slot was written by a real exception frame. +static bool cause_slot_was_written() { +#if CONFIG_IDF_TARGET_ARCH_XTENSA + return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT; +#else + return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT; +#endif +} + // Look up the exception cause as a human-readable string. // Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays // not exposed via any public API. static const char *get_exception_reason() { + uint8_t exception = s_raw_crash_data.exception; + if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) { + // Abort-class panics carry no cause register + return nullptr; + } + if (!cause_slot_was_written()) { + // Garbage from old-build or corrupt records; report just the type + return nullptr; + } #if CONFIG_IDF_TARGET_ARCH_XTENSA if (s_raw_crash_data.pseudo_excause) { // SoC-level panic: watchdog, cache error, etc. @@ -237,6 +280,16 @@ static const char *get_exception_reason() { nullptr, "LoadProhibited", "StoreProhibited", + nullptr, + nullptr, + "Cp0Dis", + "Cp1Dis", + "Cp2Dis", + "Cp3Dis", + "Cp4Dis", + "Cp5Dis", + "Cp6Dis", + "Cp7Dis", }; uint32_t cause = s_raw_crash_data.cause; if (cause < sizeof(REASON) / sizeof(REASON[0]) && REASON[cause] != nullptr) @@ -317,6 +370,56 @@ static int append_addrs_to_hint(char *buf, int size, int pos, const uint32_t *ad return pos; } +// Register holding the faulting memory address, named as in ESP-IDF's live +// register dump. The lowercase form is for old-build reports, where the +// stacktrace decoders must not match the line. +#if CONFIG_IDF_TARGET_ARCH_XTENSA +static const char *const FAULT_ADDR_REG = "EXCVADDR"; +static const char *const FAULT_ADDR_REG_LOWER = "excvaddr"; +#elif CONFIG_IDF_TARGET_ARCH_RISCV +static const char *const FAULT_ADDR_REG = "MTVAL"; +static const char *const FAULT_ADDR_REG_LOWER = "mtval"; +#endif + +// Whether the fault address is meaningful: real CPU faults with a validly +// written frame only. +static bool has_fault_addr() { + return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause && + cause_slot_was_written(); +} + +// The record was captured by a different firmware build (it survives soft +// resets, including the OTA reboot), so symbolizing its addresses against the +// current ELF would produce misleading symbols. Print them with lowercase +// labels the stacktrace decoders deliberately do not match, and skip the +// addr2line hint. One line per address so nothing is lost to a shared buffer. +// No is_return_addr() filtering here: it would inspect the current build's +// code bytes, which say nothing about addresses captured by the old build. +static uint8_t log_foreign_backtrace(const uint32_t *addrs, uint8_t count, uint8_t bt_num) { + for (uint8_t i = 0; i < count; i++) { + ESP_LOGE(TAG, " bt%d: 0x%08" PRIX32, bt_num++, addrs[i]); + } + return bt_num; +} + +static void log_foreign_addresses() { + ESP_LOGE(TAG, " Captured by a different firmware build; addresses belong to that build's ELF"); + ESP_LOGE(TAG, " pc: 0x%08" PRIX32, s_raw_crash_data.pc); + if (has_fault_addr()) { + ESP_LOGE(TAG, " %s: 0x%08" PRIX32, FAULT_ADDR_REG_LOWER, s_raw_crash_data.fault_addr); + } + uint8_t bt_num = log_foreign_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, 0); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + // Lowercase like the address labels: carries no address, matches no decoder. + ESP_LOGE(TAG, " other core (%d):", 1 - s_raw_crash_data.crashed_core); + log_foreign_backtrace(s_raw_crash_data.other_backtrace, s_raw_crash_data.other_backtrace_count, bt_num); + } +#else + (void) bt_num; // Single-core targets have no second list to continue numbering into. +#endif +} + // Intentionally uses separate ESP_LOGE calls per line instead of combining into // one multi-line log message. This ensures each address appears as its own line // on the serial console, making it possible to see partial output if the device @@ -329,12 +432,23 @@ void crash_handler_log() { ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); const char *reason = get_exception_reason(); if (reason != nullptr) { - ESP_LOGE(TAG, " Reason: %s - %s", get_exception_type(), reason); + ESP_LOGE(TAG, " Reason: %s - %s (cause %" PRIu32 ")", get_exception_type(), reason, s_raw_crash_data.cause); } else { ESP_LOGE(TAG, " Reason: %s", get_exception_type()); } ESP_LOGE(TAG, " Crashed core: %d", s_raw_crash_data.crashed_core); + if (s_raw_crash_data.build_time != s_current_build_time) { + // Captured by a different firmware build: the record survives soft resets + // including the OTA reboot, so its addresses belong to a previous ELF. + log_foreign_addresses(); + return; + } ESP_LOGE(TAG, " PC: 0x%08" PRIX32 " (fault location)", s_raw_crash_data.pc); + // Uses the same register name as ESP-IDF's live register dump so the CLI + // decodes the address when it happens to be a code address. + if (has_fault_addr()) { + ESP_LOGE(TAG, " %s: 0x%08" PRIX32 " (faulting address)", FAULT_ADDR_REG, s_raw_crash_data.fault_addr); + } log_backtrace(s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, s_raw_crash_data.reg_frame_count); #if SOC_CPU_CORES_NUM > 1 @@ -346,16 +460,23 @@ void crash_handler_log() { } #endif - // Build addr2line hint with all captured addresses for easy copy-paste + // Build addr2line hints for easy copy-paste. One line per core: the two + // backtraces are separate stacks, and a combined list decodes as one + // impossible call chain (and can overflow the buffer, dropping addresses). + static const char *const ADDR2LINE_CMD = "addr2line -pfiaC -e firmware.elf"; char hint[256]; - int pos = snprintf(hint, sizeof(hint), "Use: addr2line -pfiaC -e firmware.elf 0x%08" PRIX32, s_raw_crash_data.pc); - pos = append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, - s_raw_crash_data.reg_frame_count); -#if SOC_CPU_CORES_NUM > 1 - append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, - s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); -#endif + int pos = snprintf(hint, sizeof(hint), "Use: %s 0x%08" PRIX32, ADDR2LINE_CMD, s_raw_crash_data.pc); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.backtrace, s_raw_crash_data.backtrace_count, + s_raw_crash_data.reg_frame_count); ESP_LOGE(TAG, "%s", hint); +#if SOC_CPU_CORES_NUM > 1 + if (s_raw_crash_data.other_backtrace_count > 0) { + pos = snprintf(hint, sizeof(hint), "Other core: %s", ADDR2LINE_CMD); + append_addrs_to_hint(hint, sizeof(hint), pos, s_raw_crash_data.other_backtrace, + s_raw_crash_data.other_backtrace_count, s_raw_crash_data.other_reg_frame_count); + ESP_LOGE(TAG, "%s", hint); + } +#endif } } // namespace esphome::esp32 @@ -365,6 +486,10 @@ void crash_handler_log() { // into NOINIT memory before the normal panic handler runs. // extern "C" { +// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an +// abort; weak so builds without the task watchdog still link. +extern bool g_twdt_isr __attribute__((weak)); + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) // Names are mandated by the --wrap linker mechanism extern void __real_esp_panic_handler(panic_info_t *info); @@ -377,6 +502,22 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { s_raw_crash_data.exception = (uint8_t) info->exception; s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0; s_raw_crash_data.crashed_core = (uint8_t) info->core; + if (g_panic_abort) { + // IDF reclassifies to ABORT only inside esp_panic_handler(), after this + // wrapper captured info->exception; correct it here. TWDT is our own + // distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is + // not stored; the symbolized backtrace already identifies the site. + bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr; + s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT); + } + // Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot + s_raw_crash_data.cause = 0; + s_raw_crash_data.fault_addr = 0; + // Record which build's ELF the captured addresses belong to (RAM read, panic-safe). + // Still 0 if the panic precedes C++ dynamic initialization, so such a crash + // reports as a foreign build — conservative: addresses are shown raw instead + // of decoded. + s_raw_crash_data.build_time = esphome::esp32::s_current_build_time; #if SOC_CPU_CORES_NUM > 1 s_raw_crash_data.other_backtrace_count = 0; s_raw_crash_data.other_reg_frame_count = 0; @@ -386,7 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; - s_raw_crash_data.cause = xt_frame->exccause; + if (!g_panic_abort) { + // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots + // never wrote them and abort() traps describe only the synthetic trap. + s_raw_crash_data.cause = xt_frame->exccause; + s_raw_crash_data.fault_addr = xt_frame->excvaddr; + } s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE); } @@ -408,7 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // RISC-V: capture MEPC + RA, then scan stack for code addresses if (info->frame != nullptr) { auto *rv_frame = (RvExcFrame *) info->frame; - s_raw_crash_data.cause = rv_frame->mcause; + if (!g_panic_abort) { + // See the Xtensa branch: abort-class frames carry no valid cause/vaddr. + s_raw_crash_data.cause = rv_frame->mcause; + s_raw_crash_data.fault_addr = rv_frame->mtval; + } s_raw_crash_data.backtrace_count = capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count); } diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 4b53d3a172..74665f3126 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,4 +1,7 @@ -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// Also defines the core ISRInternalGPIOPin methods; those are only reachable +// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. +#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) #include "gpio.h" #include "esphome/core/log.h" @@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { } // namespace esphome -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO 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 36dd44155a..98aac209ec 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 ( @@ -31,9 +32,12 @@ from .const import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, esp32_ns, ) from .gpio_esp32 import esp32_validate_gpio_pin, esp32_validate_supports @@ -43,9 +47,16 @@ from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_support from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports +from .gpio_esp32_h4 import esp32_h4_validate_gpio_pin, esp32_h4_validate_supports +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) @@ -90,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 = { @@ -120,6 +132,14 @@ _esp32_validations = { pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, ), + VARIANT_ESP32H4: ESP32ValidationFunctions( + pin_validation=esp32_h4_validate_gpio_pin, + usage_validation=esp32_h4_validate_supports, + ), + VARIANT_ESP32H21: ESP32ValidationFunctions( + pin_validation=esp32_h21_validate_gpio_pin, + usage_validation=esp32_h21_validate_supports, + ), VARIANT_ESP32P4: ESP32ValidationFunctions( pin_validation=esp32_p4_validate_gpio_pin, usage_validation=esp32_p4_validate_supports, @@ -131,6 +151,11 @@ _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, + usage_validation=esp32_s31_validate_supports, ), } @@ -232,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): + cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) @@ -243,3 +269,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_h21.py b/esphome/components/esp32/gpio_esp32_h21.py new file mode 100644 index 0000000000..5ab1b7c074 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -0,0 +1,34 @@ +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 + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. +_ESP32H21_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h21_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 25: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + return value + + +def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 25: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-25)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py new file mode 100644 index 0000000000..86a4d55858 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -0,0 +1,34 @@ +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 + +# Partial set from the ESP-IDF / esptool boot-mode docs: +# https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html +# The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. +_ESP32H4_STRAPPING_PINS: set[int] = {13, 14} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_h4_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 39: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + return value + + +def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 39: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-39)") + if is_input: + # All ESP32 pins support input mode + pass + + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) + return value 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 new file mode 100644 index 0000000000..d49240723b --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -0,0 +1,51 @@ +import logging +from typing import Any + +import esphome.config_validation as cv +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} +# 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__) + + +def esp32_s31_validate_gpio_pin(value: int) -> int: + if value < 0 or value > 61: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-61)") + if value in _ESP32S31_SPI_FLASH_PINS: + raise cv.Invalid( + f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." + ) + return value + + +def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 61: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)") + if is_input: + # All ESP32 pins support input mode + pass + + 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/helpers.cpp b/esphome/components/esp32/helpers.cpp index afcec8bfc7..91b4241211 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -71,23 +71,32 @@ static bool read_valid_mac(uint8_t *mac, esp_err_t err) { return err == ESP_OK & static constexpr size_t MAC_ADDRESS_SIZE_BITS = MAC_ADDRESS_SIZE * 8; // 48 bits +// Must not use the ESPHome logger (may run before it exists, e.g. from app_main()). +bool get_custom_mac_address(uint8_t *mac) { + // has_custom_mac_address() checks the raw eFuse field, while the reads below select their + // method differently and may still fail (CRC), so the result must be validated again. + if (!has_custom_mac_address()) + return false; +#if defined(CONFIG_SOC_IEEE802154_SUPPORTED) + return read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS)); +#else + return read_valid_mac(mac, esp_efuse_mac_get_custom(mac)); +#endif +} + void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) + if (get_custom_mac_address(mac)) { + return; + } #if defined(CONFIG_SOC_IEEE802154_SUPPORTED) // When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default // returns the 802.15.4 EUI-64 address, so we read directly from eFuse instead. - // Both paths already read raw eFuse bytes, so there is no CRC-bypass fallback + // This already reads raw eFuse bytes, so there is no CRC-bypass fallback // (unlike the non-IEEE802154 path where esp_efuse_mac_get_default does CRC checks). - if (has_custom_mac_address() && - read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS))) { - return; - } if (read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_FACTORY, mac, MAC_ADDRESS_SIZE_BITS))) { return; } #else - if (has_custom_mac_address() && read_valid_mac(mac, esp_efuse_mac_get_custom(mac))) { - return; - } if (read_valid_mac(mac, esp_efuse_mac_get_default(mac))) { return; } @@ -109,7 +118,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); } bool has_custom_mac_address() { #if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC) - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; // do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails #ifndef USE_ESP32_VARIANT_ESP32 return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) && diff --git a/esphome/components/esp32/post_build.py.script b/esphome/components/esp32/post_build.py.script index b329f6b82b..f1a38f9e76 100644 --- a/esphome/components/esp32/post_build.py.script +++ b/esphome/components/esp32/post_build.py.script @@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env): flash_size = env.BoardConfig().get("upload.flash_size", "4MB") chip = env.BoardConfig().get("build.mcu", "esp32") + # PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right + # artifact names and partition offsets, including custom partition tables). The merge + # below is only a fallback and cannot honor custom layouts, so don't overwrite an image + # PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt, + # and PlatformIO's combined-image builder runs before us in that batch, so an existing + # file here is current. + output_path = firmware_path.with_suffix(".factory.bin") + if output_path.exists(): + print(f"{output_path.name} already created by PlatformIO - skipping merge") + return + sections = [] flasher_args_path = build_dir / "flasher_args.json" @@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env): print("No valid flash sections found — skipping .factory.bin creation.") return - output_path = firmware_path.with_suffix(".factory.bin") python_exe = f'"{env.subst("$PYTHONEXE")}"' cmd = [ python_exe, diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script index af12275a0b..8728e02a34 100644 --- a/esphome/components/esp32/pre_build.py.script +++ b/esphome/components/esp32/pre_build.py.script @@ -1,3 +1,5 @@ +import os + Import("env") # noqa: F821 # Remove custom_sdkconfig from the board config as it causes @@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board: del board._manifest["espidf"]["custom_sdkconfig"] if not board._manifest["espidf"]: del board._manifest["espidf"] + +# Referenced by rules in esphome/idf_component.yml; an unset env var is a +# fatal error there. Always 0: in PlatformIO builds arduino is not a managed +# IDF component. +os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0") diff --git a/esphome/components/esp32/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..f3d5844cd7 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) { @@ -103,12 +176,45 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty } s_open_err = ESP_OK; } + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new ESP32PreferenceBackend(this->make_backend_(type))); +} + +ESP32PreferenceBackend ESP32Preferences::make_backend_(uint32_t type) const { + // in_flash keeps its default of true, selecting the NVS path + ESP32PreferenceBackend backend; + backend.nvs_handle = this->nvs_handle; + backend.key = type; + return backend; +} + +bool ESP32Preferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + ESP32PreferenceBackend backend = this->make_backend_(type); + return backend.load(data, len); +} + +#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->nvs_handle = this->nvs_handle; 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()) @@ -186,6 +292,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..9125843958 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,17 +20,26 @@ 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); + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); uint32_t nvs_handle; protected: + ESP32PreferenceBackend make_backend_(uint32_t type) const; 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..7e97111686 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -1,15 +1,28 @@ from collections.abc import Callable, MutableMapping -from dataclasses import dataclass from enum import Enum import logging -import re from typing import Any from esphome import automation import esphome.codegen as cg + +# bt_uuid validation lives in the platform-neutral ble_device_base; re-exported +# here for backward compatibility. +from esphome.components.ble_device_base import ( # noqa: F401 # pylint: disable=unused-import + BT_UUID16_FORMAT as bt_uuid16_format, + BT_UUID32_FORMAT as bt_uuid32_format, + BT_UUID128_FORMAT as bt_uuid128_format, + bt_uuid, +) 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 +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ENABLE_ON_BOOT, @@ -19,10 +32,12 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, ID, TimePeriod +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType +AUTO_LOAD = ["ble_device_base"] # ble_uuid.h builds on the neutral ESPBTUUID DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] DOMAIN = "esp32_ble" @@ -120,18 +135,21 @@ def _get_required_loggers() -> set[BTLoggers]: return CORE.data.setdefault(ESP32_BLE_REQUIRED_LOGGERS_KEY, set()) -# Dataclass for handler registration counts -@dataclass -class HandlerCounts: - gap_event: int = 0 - gap_scan_event: int = 0 - gattc_event: int = 0 - gatts_event: int = 0 - ble_status_event: int = 0 - - -# Track handler registration counts for StaticVector sizing -_handler_counts = HandlerCounts() +# Handler slot counters sizing the StaticCallbackManager storage in ble.h; +# one request per register_* call below. +_request_gap_event_slot = cg.slot_counter("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") +_request_gap_scan_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT" +) +_request_gattc_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT" +) +_request_gatts_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT" +) +_request_ble_status_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT" +) def _add_callback( @@ -157,8 +175,8 @@ def _add_callback( def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None: - """Register a GAP event handler and track the count.""" - _handler_counts.gap_event += 1 + """Register a GAP event handler and request a handler slot.""" + _request_gap_event_slot() _add_callback( parent_var, "add_gap_event_callback", @@ -171,8 +189,8 @@ def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) def register_gap_scan_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GAP scan event handler and track the count.""" - _handler_counts.gap_scan_event += 1 + """Register a GAP scan event handler and request a handler slot.""" + _request_gap_scan_event_slot() _add_callback( parent_var, "add_gap_scan_event_callback", @@ -185,8 +203,8 @@ def register_gap_scan_event_handler( def register_gattc_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTc event handler and track the count.""" - _handler_counts.gattc_event += 1 + """Register a GATTc event handler and request a handler slot.""" + _request_gattc_event_slot() _add_callback( parent_var, "add_gattc_event_callback", @@ -199,8 +217,8 @@ def register_gattc_event_handler( def register_gatts_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTs event handler and track the count.""" - _handler_counts.gatts_event += 1 + """Register a GATTs event handler and request a handler slot.""" + _request_gatts_event_slot() _add_callback( parent_var, "add_gatts_event_callback", @@ -213,8 +231,8 @@ def register_gatts_event_handler( def register_ble_status_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a BLE status event handler and track the count.""" - _handler_counts.ble_status_event += 1 + """Register a BLE status event handler and request a handler slot.""" + _request_ble_status_event_slot() _add_callback( parent_var, "add_ble_status_event_callback", @@ -367,44 +385,7 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -bt_uuid16_format = "XXXX" -bt_uuid32_format = "XXXXXXXX" -bt_uuid128_format = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" - - -def bt_uuid(value): - in_value = cv.string_strict(value) - value = in_value.upper() - - if len(value) == len(bt_uuid16_format): - pattern = re.compile("^[A-F0-9]{4,}$") - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 16 bit UUID format: '{in_value}'" - ) - return value - if len(value) == len(bt_uuid32_format): - pattern = re.compile("^[A-F0-9]{8,}$") - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 32 bit UUID format: '{in_value}'" - ) - return value - if len(value) == len(bt_uuid128_format): - pattern = re.compile( - "^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$" - ) - if not pattern.match(value): - raise cv.Invalid( - f"Invalid hexadecimal value for 128 UUID format: '{in_value}'" - ) - return value - raise cv.Invalid( - f"Bluetooth UUID must be in 16 bit '{bt_uuid16_format}', 32 bit '{bt_uuid32_format}', or 128 bit '{bt_uuid128_format}' format" - ) - - -def validate_variant(_): +def validate_variant(_: ConfigType) -> None: variant = get_esp32_variant() if variant in NO_BLUETOOTH_VARIANTS: raise cv.Invalid(f"{variant} does not support Bluetooth") @@ -464,7 +445,7 @@ def validate_connection_slots(max_connections: int) -> None: ) -def final_validation(config): +def final_validation(config: ConfigType) -> None: validate_variant(config) if (name := config.get(CONF_NAME)) is not None: full_config = fv.full_config.get() @@ -535,43 +516,11 @@ def final_validation(config): # For newer chips (C3/S3/etc), different configs are used automatically add_idf_sdkconfig_option("CONFIG_BTDM_CTRL_BLE_MAX_CONN", max_connections) - return config - FINAL_VALIDATE_SCHEMA = final_validation -# This needs to be run as a job with CoroPriority.FINAL priority so that all components have -# a chance to register their handlers before the counts are added to defines. -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_handler_defines(): - # Add defines for StaticVector sizing based on handler registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - if _handler_counts.gap_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT", _handler_counts.gap_event - ) - if _handler_counts.gap_scan_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT", - _handler_counts.gap_scan_event, - ) - if _handler_counts.gattc_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT", _handler_counts.gattc_event - ) - if _handler_counts.gatts_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT", _handler_counts.gatts_event - ) - if _handler_counts.ble_status_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT", - _handler_counts.ble_status_event, - ) - - -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) @@ -599,8 +548,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() # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables @@ -657,24 +605,43 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") cg.add_define("USE_ESP32_BLE_UUID") - # Schedule the handler defines to be added after all components register - CORE.add_job(_add_ble_handler_defines) - @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) -async def ble_enabled_to_code(config, condition_id, template_arg, args): +async def ble_enabled_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(condition_id, template_arg) @automation.register_action( "ble.enable", BLEEnableAction, cv.Schema({}), synchronous=True ) -async def ble_enable_to_code(config, action_id, template_arg, args): +async def ble_enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) @automation.register_action( "ble.disable", BLEDisableAction, cv.Schema({}), synchronous=True ) -async def ble_disable_to_code(config, action_id, template_arg, args): +async def ble_disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(action_id, template_arg) + + +# ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set +# when advertising is enabled here or by esp32_ble_server / esp32_ble_beacon. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"ble_advertising.cpp": "USE_ESP32_BLE_ADVERTISING"} +) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 6bbf0d6a26..6e6fb0e30d 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: \ @@ -43,6 +58,7 @@ static const char *const TAG = "esp32_ble"; case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: \ + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: \ case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT @@ -164,6 +180,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 +211,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 +371,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 +420,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; } @@ -600,11 +643,33 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa App.wake_loop_threadsafe(); return; + // Log the result of connection parameter updates: a peer can reject or + // never answer an update, and without this the link silently stays on the + // old parameters (visible only as unexplained supervision timeouts). + case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: { + if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status); + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + else { + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(param->update_conn_params.bda, mac_s); + ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s, + param->update_conn_params.conn_int, param->update_conn_params.latency, + param->update_conn_params.timeout); + } +#endif + return; + } + // Ignore these GAP events as they are not relevant for our use case - case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT: case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm + case ESP_GAP_BLE_LOCAL_IR_EVT: // Local identity root key generated at security init + case ESP_GAP_BLE_LOCAL_ER_EVT: // Local encryption root key generated at security init return; default: @@ -631,11 +696,23 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif +void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + // The running stack owns the address (on hosted controllers it lives in + // the remote chip's efuse); null before init becomes all-zero. + const uint8_t *mac = esp_bt_dev_get_address(); + if (mac != nullptr) { + memcpy(out, mac, MAC_ADDRESS_SIZE); + } else { + memset(out, 0, MAC_ADDRESS_SIZE); + } +} + float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - const uint8_t *mac_address = esp_bt_dev_get_address(); - if (mac_address) { + uint8_t mac_address[MAC_ADDRESS_SIZE]; + this->get_mac_msb_first(mac_address); + if (mac_address_is_valid(mac_address)) { const char *io_capability_s; switch (this->io_cap_) { case ESP_IO_CAP_OUT: @@ -658,7 +735,7 @@ void ESP32BLE::dump_config() { break; } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index de8c8c2343..2a355a6c8b 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; } @@ -108,6 +108,8 @@ class ESP32BLE : public Component { void setup() override; void loop() override; void dump_config() override; + /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } @@ -236,12 +238,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 +251,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/ble_advertising.h b/esphome/components/esp32_ble/ble_advertising.h index 3cfa6f548a..6c8a97f453 100644 --- a/esphome/components/esp32_ble/ble_advertising.h +++ b/esphome/components/esp32_ble/ble_advertising.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/defines.h" +#include "ble_uuid.h" #include #include @@ -15,8 +16,6 @@ namespace esphome::esp32_ble { -class ESPBTUUID; - class BLEAdvertising { public: BLEAdvertising(uint32_t advertising_cycle_time); diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index ba87fd8805..babfa937c7 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -207,7 +207,7 @@ class BLEEvent { StatusOnlyData scan_complete; // 1 byte // Advertising complete events all have same structure // Used by: esp32_ble_beacon, esp32_ble server components - // ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, ADV_START, ADV_STOP + // ADV_DATA_SET, SCAN_RSP_DATA_SET, ADV_DATA_RAW_SET, SCAN_RSP_DATA_RAW_SET, ADV_START, ADV_STOP StatusOnlyData adv_complete; // 1 byte // RSSI complete event // Used by: ble_client (ble_rssi_sensor component) @@ -324,6 +324,9 @@ class BLEEvent { case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT: // Used by: esp32_ble_beacon this->event_.gap.adv_complete.status = p->adv_data_raw_cmpl.status; break; + case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT: // Used by: raw advertisers with scan response + this->event_.gap.adv_complete.status = p->scan_rsp_data_raw_cmpl.status; + break; case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: // Used by: esp32_ble_beacon this->event_.gap.adv_complete.status = p->adv_start_cmpl.status; break; diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp deleted file mode 100644 index 886f8237ad..0000000000 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ /dev/null @@ -1,193 +0,0 @@ -#include "ble_uuid.h" - -#ifdef USE_ESP32 -#ifdef USE_ESP32_BLE_UUID - -#include -#include -#include -#include "esphome/core/log.h" -#include "esphome/core/helpers.h" - -namespace esphome::esp32_ble { - -static const char *const TAG = "esp32_ble"; - -ESPBTUUID::ESPBTUUID() : uuid_() {} -ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_16; - ret.uuid_.uuid.uuid16 = uuid; - return ret; -} -ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_32; - ret.uuid_.uuid.uuid32 = uuid; - return ret; -} -ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_128; - memcpy(ret.uuid_.uuid.uuid128, data, ESP_UUID_LEN_128); - return ret; -} -ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) { - ESPBTUUID ret; - ret.uuid_.len = ESP_UUID_LEN_128; - for (uint8_t i = 0; i < ESP_UUID_LEN_128; i++) - ret.uuid_.uuid.uuid128[ESP_UUID_LEN_128 - 1 - i] = data[i]; - return ret; -} -ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { - ESPBTUUID ret; - if (length == 4) { - // 16-bit UUID as 4-character hex string - auto parsed = parse_hex(data, length); - if (parsed.has_value()) { - ret.uuid_.len = ESP_UUID_LEN_16; - ret.uuid_.uuid.uuid16 = parsed.value(); - } - } else if (length == 8) { - // 32-bit UUID as 8-character hex string - auto parsed = parse_hex(data, length); - if (parsed.has_value()) { - ret.uuid_.len = ESP_UUID_LEN_32; - ret.uuid_.uuid.uuid32 = parsed.value(); - } - } else if (length == 16) { // how we can have 16 byte length string reprezenting 128 bit uuid??? needs to be - // investigated (lack of time) - ret.uuid_.len = ESP_UUID_LEN_128; - memcpy(ret.uuid_.uuid.uuid128, reinterpret_cast(data), 16); - } else if (length == 36) { - // If the length of the string is 36 bytes then we will assume it is a long hex string in - // UUID format. - ret.uuid_.len = ESP_UUID_LEN_128; - int n = 0; - for (size_t i = 0; i < length; i += 2) { - if (data[i] == '-') - i++; - uint8_t msb = data[i]; - uint8_t lsb = data[i + 1]; - - if (msb > '9') - msb -= 7; - if (lsb > '9') - lsb -= 7; - ret.uuid_.uuid.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F); - } - } else { - ESP_LOGE(TAG, "ERROR: UUID value not 2, 4, 16 or 36 bytes - %s", data); - } - return ret; -} -ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { - ESPBTUUID ret; - ret.uuid_.len = uuid.len; - if (uuid.len == ESP_UUID_LEN_16) { - ret.uuid_.uuid.uuid16 = uuid.uuid.uuid16; - } else if (uuid.len == ESP_UUID_LEN_32) { - ret.uuid_.uuid.uuid32 = uuid.uuid.uuid32; - } else if (uuid.len == ESP_UUID_LEN_128) { - memcpy(ret.uuid_.uuid.uuid128, uuid.uuid.uuid128, ESP_UUID_LEN_128); - } - return ret; -} -ESPBTUUID ESPBTUUID::as_128bit() const { - if (this->uuid_.len == ESP_UUID_LEN_128) { - return *this; - } - uint8_t data[] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; - uint32_t uuid32; - if (this->uuid_.len == ESP_UUID_LEN_32) { - uuid32 = this->uuid_.uuid.uuid32; - } else { - uuid32 = this->uuid_.uuid.uuid16; - } - for (uint16_t i = 0; i < this->uuid_.len; i++) { - data[12 + i] = ((uuid32 >> i * 8) & 0xFF); - } - return ESPBTUUID::from_raw(data); -} -bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { - if (this->uuid_.len == ESP_UUID_LEN_16) { - return (this->uuid_.uuid.uuid16 >> 8) == data2 && (this->uuid_.uuid.uuid16 & 0xFF) == data1; - } else if (this->uuid_.len == ESP_UUID_LEN_32) { - for (uint8_t i = 0; i < 3; i++) { - bool a = ((this->uuid_.uuid.uuid32 >> i * 8) & 0xFF) == data1; - bool b = ((this->uuid_.uuid.uuid32 >> (i + 1) * 8) & 0xFF) == data2; - if (a && b) - return true; - } - } else { - for (uint8_t i = 0; i < 15; i++) { - if (this->uuid_.uuid.uuid128[i] == data1 && this->uuid_.uuid.uuid128[i + 1] == data2) - return true; - } - } - return false; -} -bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { - if (this->uuid_.len == uuid.uuid_.len) { - switch (this->uuid_.len) { - case ESP_UUID_LEN_16: - return this->uuid_.uuid.uuid16 == uuid.uuid_.uuid.uuid16; - case ESP_UUID_LEN_32: - return this->uuid_.uuid.uuid32 == uuid.uuid_.uuid.uuid32; - case ESP_UUID_LEN_128: - return memcmp(this->uuid_.uuid.uuid128, uuid.uuid_.uuid.uuid128, ESP_UUID_LEN_128) == 0; - default: - return false; - } - } - return this->as_128bit() == uuid.as_128bit(); -} -esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } -const char *ESPBTUUID::to_str(std::span output) const { - char *pos = output.data(); - - switch (this->uuid_.len) { - case ESP_UUID_LEN_16: - *pos++ = '0'; - *pos++ = 'x'; - *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); - *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); - *pos = '\0'; - return output.data(); - - case ESP_UUID_LEN_32: - *pos++ = '0'; - *pos++ = 'x'; - for (int shift = 28; shift >= 0; shift -= 4) { - *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); - } - *pos = '\0'; - return output.data(); - - default: - case ESP_UUID_LEN_128: - // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - for (int8_t i = 15; i >= 0; i--) { - uint8_t byte = this->uuid_.uuid.uuid128[i]; - *pos++ = format_hex_pretty_char(byte >> 4); - *pos++ = format_hex_pretty_char(byte & 0x0F); - if (i == 12 || i == 10 || i == 8 || i == 6) { - *pos++ = '-'; - } - } - *pos = '\0'; - return output.data(); - } -} -std::string ESPBTUUID::to_string() const { - char buf[UUID_STR_LEN]; - this->to_str(buf); - return std::string(buf); -} - -} // namespace esphome::esp32_ble - -#endif // USE_ESP32_BLE_UUID -#endif // USE_ESP32 diff --git a/esphome/components/esp32_ble/ble_uuid.h b/esphome/components/esp32_ble/ble_uuid.h index 503fde6945..fd8da4baee 100644 --- a/esphome/components/esp32_ble/ble_uuid.h +++ b/esphome/components/esp32_ble/ble_uuid.h @@ -1,59 +1,21 @@ #pragma once #include "esphome/core/defines.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #ifdef USE_ESP32 #ifdef USE_ESP32_BLE_UUID -#include -#include -#include -#include +// The BLE UUID type is owned by the platform-neutral ble_device_base layer; +// this header re-exports it under the historical esp32_ble name (esp32 only). +// The full historical API surface — including from_uuid()/get_uuid() with the +// ESP-IDF esp_bt_uuid_t type — is preserved on esp32 builds. + +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::esp32_ble { -/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0" -static constexpr size_t UUID_STR_LEN = 37; - -class ESPBTUUID { - public: - ESPBTUUID(); - - static ESPBTUUID from_uint16(uint16_t uuid); - - static ESPBTUUID from_uint32(uint32_t uuid); - - static ESPBTUUID from_raw(const uint8_t *data); - static ESPBTUUID from_raw_reversed(const uint8_t *data); - - static ESPBTUUID from_raw(const char *data, size_t length); - static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); } - static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); } - static ESPBTUUID from_raw(std::initializer_list data) { - return from_raw(reinterpret_cast(data.begin()), data.size()); - } - - static ESPBTUUID from_uuid(esp_bt_uuid_t uuid); - - ESPBTUUID as_128bit() const; - - bool contains(uint8_t data1, uint8_t data2) const; - - bool operator==(const ESPBTUUID &uuid) const; - bool operator!=(const ESPBTUUID &uuid) const { return !(*this == uuid); } - - esp_bt_uuid_t get_uuid() const; - - // Remove before 2026.8.0 - ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0") - std::string to_string() const; // NOLINT - const char *to_str(std::span output) const; - - protected: - esp_bt_uuid_t uuid_; -}; +using ble_device_base::UUID_STR_LEN; +using ESPBTUUID = ble_device_base::ESPBTUUID; } // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 8052c13596..e9c44284e4 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -1,10 +1,11 @@ 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 from esphome.core import TimePeriod +from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] @@ -18,7 +19,7 @@ CONF_MAX_INTERVAL = "max_interval" CONF_MEASURED_POWER = "measured_power" -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if config[CONF_MIN_INTERVAL] > config.get(CONF_MAX_INTERVAL): raise cv.Invalid("min_interval must be <= max_interval") return config @@ -61,7 +62,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_UUID") uuid = config[CONF_UUID].hex @@ -86,5 +87,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() 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_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 7f0f2c624d..e6cdde9cda 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -13,20 +13,14 @@ namespace esphome::esp32_ble_client { static const char *const TAG = "esp32_ble_client"; -// Intermediate connection parameters for standard operation -// ESP-IDF defaults (12.5-15ms) are too slow for stable connections through WiFi-based BLE proxies, -// causing disconnections. These medium parameters balance responsiveness with bandwidth usage. -static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms -static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms -// The timeout value was increased from 6s to 8s to address stability issues observed -// in certain BLE devices when operating through WiFi-based BLE proxies. The longer -// timeout reduces the likelihood of disconnections during periods of high latency. -static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s - -// Fastest connection parameters for devices with short discovery timeouts -static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum) -static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms -static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s +// Connection parameters are shared with the other GATT client backends +// (ble_device_base/ble_client_state.h) so the platforms cannot drift. +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; static constexpr uint32_t DISCONNECTING_TIMEOUT = 10000; // 10s static const esp_bt_uuid_t NOTIFY_DESC_UUID = { .len = ESP_UUID_LEN_16, @@ -72,6 +66,7 @@ void BLEClientBase::loop() { // never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call. this->release_services(); this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT); } } @@ -124,6 +119,9 @@ void BLEClientBase::connect() { } ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; + // A registration whose event never arrived must not block this connection's release. + this->services_released_ = false; + this->pending_notify_regs_ = 0; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -199,10 +197,26 @@ void BLEClientBase::release_services() { this->services_.clear(); #endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // Only the cache clean makes the stack's database unsafe to walk. + this->services_released_ = true; esp_ble_gattc_cache_clean(this->remote_bda_); #endif } +esp_err_t BLEClientBase::register_for_notify(uint16_t char_handle) { + esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, char_handle); + if (err != ESP_OK) + return err; + if (this->pending_notify_regs_ == UINT8_MAX) { + // Saturating undercounts, so the release can run before the last registration completes. + // Wrapping to zero would undercount by the full range instead, which is worse. + this->log_warning_("Too many outstanding notify registrations to track"); + return err; + } + this->pending_notify_regs_++; + return err; +} + void BLEClientBase::log_event_(const char *name) { ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name); } @@ -418,6 +432,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->log_gattc_lifecycle_event_("CLOSE"); this->release_services(); this->set_idle_(); + this->on_disconnect_complete(param->close.reason); break; } case ESP_GATTC_SEARCH_RES_EVT: { @@ -496,12 +511,20 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->log_gattc_data_event_("REG_FOR_NOTIFY"); + // The event carries no conn_id, so this is the only place the request can be retired. + if (this->pending_notify_regs_ > 0) + this->pending_notify_regs_--; if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || this->connection_type_ == espbt::ConnectionType::V3_WITHOUT_CACHE) { // Client is responsible for flipping the descriptor value // when using the cache break; } + if (this->services_released_) { + // The lookup below walks the freed GATT cache, and Bluedroid asserts on it rather than erroring. + this->log_warning_("REG_FOR_NOTIFY after services released, notifications not enabled"); + break; + } esp_gattc_descr_elem_t desc_result; uint16_t count = 1; esp_gatt_status_t descr_status = esp_ble_gattc_get_descr_by_char_handle( diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 4e0b22cc29..e4b9cd5100 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -44,6 +44,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void unconditional_disconnect(); void release_services(); + /// Register for notifications, holding the service release until the registration completes. + esp_err_t register_for_notify(uint16_t char_handle); + + /// True while a register_for_notify() request has not completed. + bool notify_registration_pending() const { return this->pending_notify_regs_ > 0; } + bool connected() { return this->state() == espbt::ClientState::ESTABLISHED; } void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; } @@ -86,6 +92,8 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint16_t get_conn_id() const { return this->conn_id_; } uint64_t get_address() const { return this->address_; } bool is_paired() const { return this->paired_; } + // The proxy clears this when a bond is removed while the link is up. + void set_unpaired() { this->paired_ = false; } uint8_t get_connection_index() const { return this->connection_index_; } @@ -125,9 +133,15 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { espbt::ConnectionType connection_type_{espbt::ConnectionType::V1}; uint8_t connection_index_; uint8_t service_count_{0}; // ESP32 has max handles < 255, typical devices have < 50 services + // Outstanding register_for_notify() requests + // A count, not per-request state, so a raw esp_ble_gattc_register_for_notify() on the same client can retire one + // services_released_ is the backstop if that ever lets the release run early + uint8_t pending_notify_regs_{0}; bool auto_connect_{false}; bool paired_{false}; - // 6 bytes used, 2 bytes padding + // Set only when release_services() cleans the stack's GATT cache, which no API may then walk + bool services_released_{false}; + // 8 bytes used, no padding void log_event_(const char *name); void log_gattc_lifecycle_event_(const char *name); @@ -140,6 +154,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void log_gattc_warning_(const char *operation, esp_err_t err); void log_connection_params_(const char *param_type); void handle_connection_result_(esp_err_t ret); + /// Hook called once a connection has been fully torn down (after release_services() and + /// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout. + /// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state) + /// override this to release that state. `reason` is the controller reason code, or + /// ESP_GATT_CONN_TIMEOUT for the safety-timeout path. + virtual void on_disconnect_complete(esp_err_t reason) {} /// Transition to IDLE and reset conn_id — call when the connection is fully dead. void set_idle_() { this->set_state(espbt::ClientState::IDLE); @@ -149,6 +169,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void set_disconnecting_() { this->disconnecting_started_ = millis(); this->set_state(espbt::ClientState::DISCONNECTING); + // BluetoothConnection::loop() disables the component loop after service discovery + // completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT + // gets lost. Re-enable the loop so the 10s safety timeout can force IDLE. + this->enable_loop(); } // Compact error logging helpers to reduce flash usage void log_error_(const char *message); 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 7bf3092a4e..855a3be29b 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 @@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29 MODEL_CHARACTERISTIC_UUID = 0x2A24 FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26 +# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit. +_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB" + + +def uuid_is(uuid: int | str, uuid16: int) -> bool: + """Return True if a validated UUID refers to the given 16-bit short UUID. + + A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an + uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every + representation of the same UUID must be considered equivalent. + """ + if isinstance(uuid, int): + return uuid == uuid16 + return uuid.upper() in ( + f"{uuid16:04X}", + f"{uuid16:08X}", + f"{uuid16:08X}{_BASE_UUID_SUFFIX}", + ) + + # Core key to store the global configuration KEY_NOTIFY_REQUIRED = "notify_required" KEY_SET_VALUE = "set_value" @@ -195,7 +215,7 @@ def create_description_cud(char_config): return char_config # If the config displays a description, there cannot be a descriptor with the CUD UUID for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present" ) @@ -218,7 +238,7 @@ def create_notify_cccd(char_config): return char_config # If the CCCD descriptor is already present, return the config for desc in char_config[CONF_DESCRIPTORS]: - if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID: + if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID): # Check if the WRITE property is set if not desc[CONF_WRITE]: raise cv.Invalid( @@ -244,7 +264,7 @@ def create_device_information_service(config): # If there is already a device information service, # there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties for service in config[CONF_SERVICES]: - if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): if ( CONF_MODEL in config or CONF_MANUFACTURER in config @@ -287,7 +307,7 @@ def create_device_information_service(config): return config -def final_validate_config(config): +def final_validate_config(config) -> None: # Validate max_clients does not exceed esp32_ble max_connections max_clients = config[CONF_MAX_CLIENTS] if max_clients > 1: @@ -335,7 +355,6 @@ def final_validate_config(config): raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has both a set_value action and a templated value" ) - return config def validate_value_type(value_config): @@ -592,7 +611,7 @@ async def to_code(config): ) for char_conf in service_config[CONF_CHARACTERISTICS]: await to_code_characteristic(service_var, char_conf) - if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID: + if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID): cg.add(var.set_device_information_service(service_var)) else: cg.add(var.enqueue_start_service(service_var)) @@ -612,7 +631,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.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index cc519846be..4d364b4655 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -196,42 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt (*this->on_read_callback_)(param->read.conn_id); } - uint16_t max_offset = 22; - + // Use the client-supplied offset for long reads; short reads always start at 0. + // The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we + // just provide as much data as we have from the requested offset and let the stack + // handle framing. The client issues subsequent blob reads with increasing offsets + // until it has received the whole value. + const uint16_t offset = param->read.is_long ? param->read.offset : 0; + esp_gatt_status_t status = ESP_GATT_OK; esp_gatt_rsp_t response; - if (param->read.is_long) { - if (this->value_read_offset_ >= this->value_.size()) { - response.attr_value.len = 0; - response.attr_value.offset = this->value_read_offset_; - this->value_read_offset_ = 0; - } else if (this->value_.size() - this->value_read_offset_ < max_offset) { - // Last message in the chain - response.attr_value.len = this->value_.size() - this->value_read_offset_; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ = 0; - } else { - response.attr_value.len = max_offset; - response.attr_value.offset = this->value_read_offset_; - memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len); - this->value_read_offset_ += max_offset; - } + response.attr_value.offset = offset; + + if (offset > this->value_.size()) { + status = ESP_GATT_INVALID_OFFSET; + response.attr_value.len = 0; } else { - response.attr_value.offset = 0; - if (this->value_.size() + 1 > max_offset) { - response.attr_value.len = max_offset; - this->value_read_offset_ = max_offset; - } else { - response.attr_value.len = this->value_.size(); + size_t remaining = this->value_.size() - offset; + if (remaining > ESP_GATT_MAX_ATTR_LEN) { + ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", + static_cast(remaining), ESP_GATT_MAX_ATTR_LEN); + remaining = ESP_GATT_MAX_ATTR_LEN; } - memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len); + response.attr_value.len = remaining; + memcpy(response.attr_value.value, this->value_.data() + offset, remaining); } response.attr_value.handle = this->handle_; response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE; esp_err_t err = - esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response); + esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err); } diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 94c7495cbd..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(); @@ -79,7 +79,6 @@ class BLECharacteristic { esp_gatt_char_prop_t properties_; uint16_t handle_{0xFFFF}; - uint16_t value_read_offset_{0}; std::vector value_; std::vector descriptors_; diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 5ca80d6a7a..3dcac3691c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -77,6 +77,10 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_ case ESP_GATTS_WRITE_EVT: { if (this->handle_ != param->write.handle) break; + if (param->write.len > this->value_.attr_max_len) { + ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", param->write.len, this->value_.attr_max_len); + break; + } this->value_.attr_len = param->write.len; memcpy(this->value_.attr_value, param->write.value, param->write.len); if (this->on_write_callback_) { 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 b4e9ed004e..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) @@ -77,13 +77,15 @@ template class BLECharacteristicSetValueAction : public Actionparent_->set_value(this->buffer_.value(x...)); // Set the listener for read events - this->parent_->on_read([this, x...](uint16_t id) { + // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference + // (e.g. climate on_control's ClimateCall&). See #17142. + this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read this->parent_->set_value(this->buffer_.value(x...)); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); } protected: @@ -92,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 { @@ -110,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..906144e5fd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,12 +1,20 @@ from __future__ import annotations +import copy from dataclasses import dataclass 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 import ble_device_base, esp32_ble, ota +from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW +from esphome.components.esp32 import ( + add_idf_sdkconfig_option, + idf_version, + request_bluetooth, + request_software_coexistence, +) from esphome.components.esp32_ble import ( IDF_MAX_CONNECTIONS, BTLoggers, @@ -31,18 +39,20 @@ from esphome.const import ( CONF_SERVICE_UUID, CONF_TRIGGER_ID, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.enum import StrEnum from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble"] +DOMAIN = "esp32_ble_tracker" + +AUTO_LOAD = ["ble_device_base", "esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] +ble_device_base.register_hub_provider("esp32_ble_tracker") + CONF_ESP32_BLE_ID = "esp32_ble_id" -CONF_SCAN_PARAMETERS = "scan_parameters" -CONF_WINDOW = "window" -CONF_ON_SCAN_END = "on_scan_end" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" _LOGGER = logging.getLogger(__name__) @@ -53,16 +63,8 @@ class BLEFeatures(StrEnum): ESP_BT_DEVICE = "ESP_BT_DEVICE" -# Dataclass for registration counts -@dataclass -class RegistrationCounts: - listeners: int = 0 - clients: int = 0 - - -# CORE.data keys for state management +# CORE.data key for state management ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY = "esp32_ble_tracker_required_features" -ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY = "esp32_ble_tracker_registration_counts" def _get_required_features() -> set[BLEFeatures]: @@ -70,11 +72,11 @@ def _get_required_features() -> set[BLEFeatures]: return CORE.data.setdefault(ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY, set()) -def _get_registration_counts() -> RegistrationCounts: - """Get the registration counts from CORE.data.""" - return CORE.data.setdefault( - ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY, RegistrationCounts() - ) +# Slot counters sizing the tracker's StaticVector storage; one request per +# registered listener or client. +CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT" +_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") +_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -89,6 +91,7 @@ def register_ble_features(features: set[BLEFeatures]) -> None: esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") ESP32BLETracker = esp32_ble_tracker_ns.class_( "ESP32BLETracker", + ble_device_base.BLEHub, cg.Component, cg.Parented.template(esp32_ble.ESP32BLE), ) @@ -121,25 +124,6 @@ ESP32BLEStopScanAction = esp32_ble_tracker_ns.class_( ) -def validate_scan_parameters(config): - duration = config[CONF_DURATION] - interval = config[CONF_INTERVAL] - window = config[CONF_WINDOW] - - if window > interval: - raise cv.Invalid( - f"Scan window ({window}) needs to be smaller than scan interval ({interval})" - ) - - if interval.total_milliseconds * 3 > duration.total_milliseconds: - raise cv.Invalid( - "Scan duration needs to be at least three times the scan interval to" - "cover all BLE channels." - ) - - return config - - def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: if CONF_MAX_CONNECTIONS in config: _LOGGER.warning( @@ -149,26 +133,141 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config -def as_hex(value): - return cg.RawExpression(f"0x{value}ULL") +# ESP-IDF 5.5.5 fixed a coexistence bug on the ESP32 where BLE scans ran far +# longer than the configured window (espressif/esp-idf#18931). Before the fix, +# the default 30 ms window in a 320 ms interval effectively scanned at a much +# higher duty cycle than requested; with the fix, that same default only +# listens 9.4 % of the time and misses most advertisements when wifi shares +# the radio. Espressif recommends setting the window equal to the interval in +# that case: the coexistence arbiter still shares the radio with wifi, and +# BLE uses the airtime wifi does not claim. +IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) + +# Above this the scanner holds the shared radio long enough that wifi drops +# packets and connections on some access points (others cope fine, which is +# why this is a warning and not an error); old proxy configs with 1100 ms +# windows are a recurring cause of instability (esphome/esphome#18655). Only +# wifi shares the radio; long windows are fine on ethernet builds. +MAX_RECOMMENDED_WIFI_SCAN_WINDOW = TimePeriod(milliseconds=600) -def as_hex_array(value): - value = value.replace("-", "") - cpp_array = [ - f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] - ] - return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}") +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + connection_window_injected: bool = False -def as_reversed_hex_array(value): - value = value.replace("-", "") - cpp_array = [ - f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)] - ] - return cg.RawExpression( - f"(uint8_t*)(const uint8_t[16]){{{','.join(reversed(cpp_array))}}}" +def _get_data() -> TrackerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = TrackerData() + return CORE.data[DOMAIN] + + +def _scan_window_default() -> TimePeriod: + """Schema default for the scan window. + + Records that the user did not set a window, so _raise_defaulted_scan_window + can tell a defaulted 30 ms from an explicit one; the raise itself must wait + for the outer schema because it depends on software_coexistence, a sibling + key not yet resolved here. + """ + _get_data().scan_window_defaulted = True + return cv.positive_time_period(ble_device_base.DEFAULT_SCAN_WINDOW) + + +def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: + """Raise a defaulted scan window to the interval where that is safe. + + Only when the coexistence arbiter is compiled in (software_coexistence, + present iff wifi is configured and not disabled by the user) and the IDF + honors the window strictly (>= 5.5.5); without the arbiter a full-duty + scan would starve wifi outright, and a user-set window is never touched. + Raising to the interval cannot invalidate the already-validated + parameters, so no re-validation is needed. The connection window is + checked against the window here, after the raise. + """ + params = config[CONF_SCAN_PARAMETERS] + if ( + _get_data().scan_window_defaulted + and config.get(CONF_SOFTWARE_COEXISTENCE) + and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION + ): + # Copy so the config dump shows a plain value instead of a YAML + # anchor/alias pair pointing at the interval. + params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL]) + # Arm the connection-time fallback unless the user set one. Injected + # after validation; safe because it equals the validated window default. + if CONF_CONNECTION_SCAN_WINDOW not in params: + params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period( + ble_device_base.DEFAULT_SCAN_WINDOW + ) + _get_data().connection_window_injected = True + if ( + connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW) + ) is not None and connection_window > params[CONF_WINDOW]: + # A larger value would widen the scan during connections. + raise cv.Invalid( + f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be " + f"smaller than the scan window ({params[CONF_WINDOW]})", + path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW], + ) + return config + + +def _warn_long_scan_window_with_wifi(config: ConfigType) -> ConfigType: + """Warn when the scan window is long enough to starve wifi. + + Runs after _raise_defaulted_scan_window so it sees the final window. + software_coexistence is only present when wifi is configured, so ethernet + builds never warn: BLE has the radio to itself there. Presence is what + matters, not the value; with the arbiter disabled a long window starves + wifi outright. + """ + params = config[CONF_SCAN_PARAMETERS] + window = params[CONF_WINDOW] + if CONF_SOFTWARE_COEXISTENCE not in config: + return config + if window <= MAX_RECOMMENDED_WIFI_SCAN_WINDOW: + return config + if _get_data().scan_window_defaulted: + # The window was raised to match the interval, so point at the key the + # user actually set. + _LOGGER.warning( + "BLE scan interval of %s sets the scan window to the same value, " + "which starves wifi on the same radio and can cause wifi disconnects " + "depending on the access point; keep the interval at or below %s " + "(for example interval: 320ms). Long windows are only a problem with " + "wifi, they are fine on ethernet", + params[CONF_INTERVAL], + MAX_RECOMMENDED_WIFI_SCAN_WINDOW, + ) + return config + _LOGGER.warning( + "BLE scan window of %s with wifi on the same radio starves wifi and " + "can cause wifi disconnects depending on the access point; keep the " + "window at or below %s (for example interval: 320ms, window: 300ms). " + "Long windows are only a problem with wifi, they are fine on ethernet", + window, + MAX_RECOMMENDED_WIFI_SCAN_WINDOW, ) + return config + + +# 320 ms is the ESP-IDF reference scan interval; the shared schema also +# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects +# window/interval pairs that collapse to the same 0.625 ms unit count. +# The window default is conditional (see _scan_window_default above). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "320ms", window_default=_scan_window_default, connection_window=True +) + +# Codegen helpers are owned by ble_device_base; kept under the historical names +# here for the components that import them from this module. +as_hex = ble_device_base.as_hex +as_hex_array = ble_device_base.as_hex_array +as_reversed_hex_array = ble_device_base.as_reversed_hex_array CONFIG_SCHEMA = cv.All( @@ -179,24 +278,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAX_CONNECTIONS): cv.All( cv.positive_int, cv.Range(min=0, max=IDF_MAX_CONNECTIONS) ), - cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All( - cv.Schema( - { - cv.Optional( - CONF_DURATION, default="5min" - ): cv.positive_time_period_seconds, - cv.Optional( - CONF_INTERVAL, default="320ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_WINDOW, default="30ms" - ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ACTIVE, default=True): cv.boolean, - cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean, - } - ), - validate_scan_parameters, - ), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( @@ -234,6 +316,8 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, + _raise_defaulted_scan_window, + _warn_long_scan_window_with_wifi, ) @@ -246,10 +330,17 @@ ESP_BLE_DEVICE_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.BLE_SCAN) + # Behavior parity with the pre-split tracker: IRK resolution is always + # available on esp32 (sensors with irk: worked without opting in). + ble_device_base.request_irk_support() + + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_ESP32_BLE_TRACKER") + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -262,8 +353,27 @@ async def to_code(config): params = config[CONF_SCAN_PARAMETERS] cg.add(var.set_scan_duration(params[CONF_DURATION])) - cg.add(var.set_scan_interval(int(params[CONF_INTERVAL].total_milliseconds / 0.625))) - cg.add(var.set_scan_window(int(params[CONF_WINDOW].total_milliseconds / 0.625))) + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW]))) + if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None: + # Emitted at FINAL so a scan-only build, where the guarded C++ path + # compiles out, skips the call entirely. + window_units = ble_device_base.to_ble_units(connection_window) + + @coroutine_with_priority(CoroPriority.FINAL) + async def _emit_connection_scan_window() -> None: + if cg.get_slot_count(CLIENT_COUNT_DEFINE): + cg.add(var.set_connection_scan_window(window_units)) + elif not _get_data().connection_window_injected: + # Warn only for a user-set value; the injected default drops silently. + _LOGGER.warning( + "'%s' has no effect because this build has no BLE client " + "components (for example bluetooth_proxy with active " + "connections, or ble_client)", + CONF_CONNECTION_SCAN_WINDOW, + ) + + CORE.add_job(_emit_connection_scan_window) cg.add(var.set_scan_active(params[CONF_ACTIVE])) cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS])) @@ -275,17 +385,15 @@ async def to_code(config): ): register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - registration_counts = _get_registration_counts() - for conf in config.get(CONF_ON_BLE_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if CONF_MAC_ADDRESS in conf: addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] cg.add(trigger.set_addresses(addr_list)) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format): cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID]))) @@ -298,7 +406,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format): cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID]))) @@ -311,13 +419,13 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_SCAN_END, []): - registration_counts.listeners += 1 + _request_listener_slot() 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 @@ -339,25 +447,19 @@ async def to_code(config): # chance to call register_ble_tracker and register_client before the list is checked # and added to the global defines list. @coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_features(): +async def _add_ble_features() -> None: # Add feature-specific defines based on what's needed required_features = _get_required_features() + # Sensors registered through the neutral ble_device_base path (BLEHub) need + # the parsed-device pipeline compiled in, exactly like esp32-path listeners. + if cg.get_slot_count(ble_device_base.LISTENER_COUNT_DEFINE): + # The neutral (BLEHub) listener count define itself is emitted by + # ble_device_base's own job; only the feature coupling lives here. + required_features.add(BLEFeatures.ESP_BT_DEVICE) if BLEFeatures.ESP_BT_DEVICE in required_features: cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") - # Add defines for StaticVector sizing based on registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - registration_counts = _get_registration_counts() - if registration_counts.listeners > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", registration_counts.listeners - ) - if registration_counts.clients > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", registration_counts.clients - ) - ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( { @@ -374,8 +476,11 @@ ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( synchronous=True, ) async def esp32_ble_tracker_start_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_CONTINUOUS], args, cg.bool_) @@ -399,8 +504,11 @@ ESP32_BLE_STOP_SCAN_ACTION_SCHEMA = automation.maybe_simple_id( synchronous=True, ) async def esp32_ble_tracker_stop_scan_action_to_code( - config, action_id, template_arg, args -): + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -410,7 +518,7 @@ async def register_ble_device( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().listeners += 1 + _request_listener_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -418,26 +526,12 @@ async def register_ble_device( async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var -async def register_raw_ble_device( - var: cg.SafeExpType, config: ConfigType -) -> cg.SafeExpType: - """Register a BLE device listener that only needs raw advertisement data. - - This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice - will not be compiled in if this is the only registration method used. - """ - _get_registration_counts().listeners += 1 - paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) - cg.add(paren.register_listener(var)) - return var - - async def register_raw_client( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: @@ -446,7 +540,7 @@ async def register_raw_client( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 6d26040ccb..541b63b2fd 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -3,11 +3,13 @@ #include "esphome/core/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include + #ifdef USE_ESP32 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 +30,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 +56,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 +84,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 +94,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 +113,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.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index f57cb7f5dc..5339565a32 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -27,15 +27,6 @@ #include #endif -#ifdef USE_ESP32_BLE_DEVICE -#ifdef USE_BLE_TRACKER_PSA_AES -#include -#else -#define MBEDTLS_AES_ALT -#include -#endif -#endif // USE_ESP32_BLE_DEVICE - // bt_trace.h #undef TAG @@ -43,32 +34,8 @@ namespace esphome::esp32_ble_tracker { static const char *const TAG = "esp32_ble_tracker"; -// BLE advertisement max: 31 bytes adv data + 31 bytes scan response -static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62; - ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -const char *client_state_to_string(ClientState state) { - switch (state) { - case ClientState::INIT: - return "INIT"; - case ClientState::DISCONNECTING: - return "DISCONNECTING"; - case ClientState::IDLE: - return "IDLE"; - case ClientState::DISCOVERED: - return "DISCOVERED"; - case ClientState::CONNECTING: - return "CONNECTING"; - case ClientState::CONNECTED: - return "CONNECTED"; - case ClientState::ESTABLISHED: - return "ESTABLISHED"; - default: - return "UNKNOWN"; - } -} - float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void ESP32BLETracker::setup() { @@ -88,6 +55,7 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + ESP_LOGD(TAG, "Stopping scan for OTA"); this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT @@ -154,6 +122,9 @@ void ESP32BLETracker::loop() { // - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_() // - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or // connecting client finishes (state change), or scanner reaches RUNNING/IDLE + // - connection-window restart: scan_params_ is only written in start_scan_() + // (which changes scanner state via set_scanner_state_()), and + // counts.active/disconnecting only change on client state changes // // All conditions that affect the logic below are tied to state changes that increment // state_version_, so the fast path is safe. @@ -176,6 +147,19 @@ void ESP32BLETracker::loop() { (this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) { this->handle_scanner_failure_(); } + +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // The programmed window no longer matches the connection state (typically + // the last connection dropped): restart so the right window applies now + // instead of at the end of the scan period. Continuous only (a user-started + // scan would not restart); !disconnecting matches the restart gate below. + if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting && + this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) { + // Same logical scan period continues: no on_scan_end sweeps for this + // restart. Only armed when the stop was issued. + this->skip_next_scan_end_ = this->stop_scan_(); + } +#endif /* Avoid starting the scanner if: @@ -223,20 +207,27 @@ void ESP32BLETracker::loop() { void ESP32BLETracker::start_scan() { this->start_scan_(true); } void ESP32BLETracker::stop_scan() { - ESP_LOGD(TAG, "Stopping scan."); + // V to match the start log: the mode-switch and OTA callers narrate their + // reason at D themselves, and the user-facing stop action is deliberate. + ESP_LOGV(TAG, "Stopping scan."); this->scan_continuous_ = false; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // The window-change restart is abandoned with continuous scanning. + this->skip_next_scan_end_ = false; +#endif this->stop_scan_(); } void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); } -void ESP32BLETracker::stop_scan_() { +bool ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - // If scanner is already idle, there's nothing to stop - this is not an error - if (this->scanner_state_ != ScannerState::IDLE) { + // IDLE means there is nothing to stop; STOPPING means a stop is already in + // flight and will finish on its own. Neither is an error. + if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); } - return; + return false; } // Reset timeout state machine when stopping scan this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; @@ -244,8 +235,9 @@ void ESP32BLETracker::stop_scan_() { esp_err_t err = esp_ble_gap_stop_scanning(); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err); - return; + return false; } + return true; } void ESP32BLETracker::start_scan_(bool first) { @@ -259,20 +251,29 @@ void ESP32BLETracker::start_scan_(bool first) { } this->set_scanner_state_(ScannerState::STARTING); ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING."); - if (!first) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) - listener->on_scan_end(); + if (!first) + this->notify_scan_end_(); +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + this->skip_next_scan_end_ = false; #endif - } #ifdef USE_ESP32_BLE_DEVICE - this->already_discovered_.clear(); + this->discovered_log_.clear(); #endif this->scan_params_.scan_type = this->scan_active_ ? BLE_SCAN_TYPE_ACTIVE : BLE_SCAN_TYPE_PASSIVE; this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC; this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL; this->scan_params_.scan_interval = this->scan_interval_; - this->scan_params_.scan_window = this->scan_window_; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // Count fresh: an automation can start a scan before loop() refreshes the counts. + const uint32_t window = this->desired_scan_window_(this->count_client_states_().active); + if (window != this->scan_window_) { + // Guarantee the connection airtime instead of scanning wall to wall. + ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window); + } +#else + const uint32_t window = this->scan_window_; +#endif + this->scan_params_.scan_window = window; // Start timeout monitoring in loop() instead of using scheduler // This prevents false reboots when the loop is blocked @@ -300,7 +301,17 @@ void ESP32BLETracker::register_client(ESPBTClient *client) { // Safe because ESP32BLETracker (singleton) outlives all registered clients. client->set_tracker_state_version(&this->state_version_); this->clients_.push_back(client); - this->recalculate_advertisement_parser_types(); + // Registration is add-only, so the flag is a monotonic OR. + if (client->wants_parsed_advertisements()) + this->parse_advertisements_ = true; +#endif +} + +void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *listener) { +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + // Neutral BLEHub path (migrated sensors): parsed-advertisement consumers only. + this->neutral_listeners_.push_back(listener); + this->parse_advertisements_ = true; #endif } @@ -308,30 +319,7 @@ void ESP32BLETracker::register_listener(ESPBTDeviceListener *listener) { #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT listener->set_parent(this); this->listeners_.push_back(listener); - this->recalculate_advertisement_parser_types(); -#endif -} - -void ESP32BLETracker::recalculate_advertisement_parser_types() { - this->raw_advertisements_ = false; - this->parse_advertisements_ = false; -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - if (listener->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - if (client->get_advertisement_parser_type() == AdvertisementParserType::PARSED_ADVERTISEMENTS) { - this->parse_advertisements_ = true; - } else { - this->raw_advertisements_ = true; - } - } + this->parse_advertisements_ = true; #endif } @@ -429,275 +417,13 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; - for (auto *listener : this->scanner_state_listeners_) { - listener->on_scanner_state(state); +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + if (this->scanner_state_callback_.is_set()) { + this->scanner_state_callback_.invoke(state); } -} - -#ifdef USE_ESP32_BLE_DEVICE -ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(beacon_data_)); } -optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { - if (!data.uuid.contains(0x4C, 0x00)) - return {}; - - if (data.data.size() != 23) - return {}; - return ESPBLEiBeacon(data.data.data()); -} - -void ESPBTDevice::parse_scan_rst(const BLEScanResult &scan_result) { - this->scan_result_ = &scan_result; - for (uint8_t i = 0; i < ESP_BD_ADDR_LEN; i++) - this->address_[i] = scan_result.bda[i]; - this->address_type_ = static_cast(scan_result.ble_addr_type); - this->rssi_ = scan_result.rssi; - - // Parse advertisement data directly - uint8_t total_len = scan_result.adv_data_len + scan_result.scan_rsp_len; - this->parse_adv_(scan_result.ble_adv, total_len); - -#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE - ESP_LOGVV(TAG, "Parse Result:"); - const char *address_type; - switch (this->address_type_) { - case BLE_ADDR_TYPE_PUBLIC: - address_type = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type = "RPA_RANDOM"; - break; - default: - address_type = "UNKNOWN"; - break; - } - ESP_LOGVV(TAG, " Address: %02X:%02X:%02X:%02X:%02X:%02X (%s)", this->address_[0], this->address_[1], - this->address_[2], this->address_[3], this->address_[4], this->address_[5], address_type); - - ESP_LOGVV(TAG, " RSSI: %d", this->rssi_); - ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str()); - for (auto &it : this->tx_powers_) { - ESP_LOGVV(TAG, " TX Power: %d", it); - } - if (this->appearance_.has_value()) { - ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_); - } - if (this->ad_flag_.has_value()) { - ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_); - } - for (auto &uuid : this->service_uuids_) { - char uuid_buf[esp32_ble::UUID_STR_LEN]; - uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " Service UUID: %s", uuid_buf); - } - char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)]; - for (auto &data : this->manufacturer_datas_) { - auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(data); - if (ibeacon.has_value()) { - ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - char uuid_buf[esp32_ble::UUID_STR_LEN]; - ibeacon.value().get_uuid().to_str(uuid_buf); - ESP_LOGVV(TAG, " UUID: %s", uuid_buf); - ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major()); - ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor()); - ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power()); - } else { - char uuid_buf[esp32_ble::UUID_STR_LEN]; - data.uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", uuid_buf, - format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); - } - } - for (auto &data : this->service_datas_) { - ESP_LOGVV(TAG, " Service data:"); - char uuid_buf[esp32_ble::UUID_STR_LEN]; - data.uuid.to_str(uuid_buf); - ESP_LOGVV(TAG, " UUID: %s", uuid_buf); - ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); - } - - ESP_LOGVV(TAG, " Adv data: %s", - format_hex_pretty_to(hex_buf, scan_result.ble_adv, scan_result.adv_data_len + scan_result.scan_rsp_len)); #endif } -void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { - size_t offset = 0; - - while (offset + 2 < len) { - const uint8_t field_length = payload[offset++]; // First byte is length of adv record - if (field_length == 0) { - continue; // Possible zero padded advertisement data - } - - // Validate field fits in remaining payload - if (offset + field_length > len) { - break; - } - - // first byte of adv record is adv record type - const uint8_t record_type = payload[offset++]; - const uint8_t *record = &payload[offset]; - const uint8_t record_length = field_length - 1; - offset += record_length; - - // See also Generic Access Profile Assigned Numbers: - // https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ See also ADVERTISING AND SCAN - // RESPONSE DATA FORMAT: https://www.bluetooth.com/specifications/bluetooth-core-specification/ (vol 3, part C, 11) - // See also Core Specification Supplement: https://www.bluetooth.com/specifications/bluetooth-core-specification/ - // (called CSS here) - - switch (record_type) { - case ESP_BLE_AD_TYPE_NAME_SHORT: - case ESP_BLE_AD_TYPE_NAME_CMPL: { - // CSS 1.2 LOCAL NAME - // "The Local Name data type shall be the same as, or a shortened version of, the local name assigned to the - // device." CSS 1: Optional in this context; shall not appear more than once in a block. - // SHORTENED LOCAL NAME - // "The Shortened Local Name data type defines a shortened version of the Local Name data type. The Shortened - // Local Name data type shall not be used to advertise a name that is longer than the Local Name data type." - if (record_length > this->name_.length()) { - this->name_ = std::string(reinterpret_cast(record), record_length); - } - break; - } - case ESP_BLE_AD_TYPE_TX_PWR: { - // CSS 1.5 TX POWER LEVEL - // "The TX Power Level data type indicates the transmitted power level of the packet containing the data type." - // CSS 1: Optional in this context (may appear more than once in a block). - this->tx_powers_.push_back(*record); - break; - } - case ESP_BLE_AD_TYPE_APPEARANCE: { - // CSS 1.12 APPEARANCE - // "The Appearance data type defines the external appearance of the device." - // See also https://www.bluetooth.com/specifications/gatt/characteristics/ - // CSS 1: Optional in this context; shall not appear more than once in a block and shall not appear in both - // the AD and SRD of the same extended advertising interval. - this->appearance_ = *reinterpret_cast(record); - break; - } - case ESP_BLE_AD_TYPE_FLAG: { - // CSS 1.3 FLAGS - // "The Flags data type contains one bit Boolean flags. The Flags data type shall be included when any of the - // Flag bits are non-zero and the advertising packet is connectable, otherwise the Flags data type may be - // omitted." - // CSS 1: Optional in this context; shall not appear more than once in a block. - this->ad_flag_ = *record; - break; - } - // CSS 1.1 SERVICE UUID - // The Service UUID data type is used to include a list of Service or Service Class UUIDs. - // There are six data types defined for the three sizes of Service UUIDs that may be returned: - // CSS 1: Optional in this context (may appear more than once in a block). - case ESP_BLE_AD_TYPE_16SRV_CMPL: - case ESP_BLE_AD_TYPE_16SRV_PART: { - // • 16-bit Bluetooth Service UUIDs - for (uint8_t i = 0; i < record_length / 2; i++) { - this->service_uuids_.push_back(ESPBTUUID::from_uint16(*reinterpret_cast(record + 2 * i))); - } - break; - } - case ESP_BLE_AD_TYPE_32SRV_CMPL: - case ESP_BLE_AD_TYPE_32SRV_PART: { - // • 32-bit Bluetooth Service UUIDs - for (uint8_t i = 0; i < record_length / 4; i++) { - this->service_uuids_.push_back(ESPBTUUID::from_uint32(*reinterpret_cast(record + 4 * i))); - } - break; - } - case ESP_BLE_AD_TYPE_128SRV_CMPL: - case ESP_BLE_AD_TYPE_128SRV_PART: { - // • Global 128-bit Service UUIDs - this->service_uuids_.push_back(ESPBTUUID::from_raw(record)); - break; - } - case ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE: { - // CSS 1.4 MANUFACTURER SPECIFIC DATA - // "The Manufacturer Specific data type is used for manufacturer specific data. The first two data octets shall - // contain a company identifier from Assigned Numbers. The interpretation of any other octets within the data - // shall be defined by the manufacturer specified by the company identifier." - // CSS 1: Optional in this context (may appear more than once in a block). - if (record_length < 2) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast(record)); - data.data.assign(record + 2UL, record + record_length); - this->manufacturer_datas_.push_back(data); - break; - } - - // CSS 1.11 SERVICE DATA - // "The Service Data data type consists of a service UUID with the data associated with that service." - // CSS 1: Optional in this context (may appear more than once in a block). - case ESP_BLE_AD_TYPE_SERVICE_DATA: { - // «Service Data - 16 bit UUID» - // Size: 2 or more octets - // The first 2 octets contain the 16 bit Service UUID fol- lowed by additional service data - if (record_length < 2) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint16(*reinterpret_cast(record)); - data.data.assign(record + 2UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_32SERVICE_DATA: { - // «Service Data - 32 bit UUID» - // Size: 4 or more octets - // The first 4 octets contain the 32 bit Service UUID fol- lowed by additional service data - if (record_length < 4) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_32SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_uint32(*reinterpret_cast(record)); - data.data.assign(record + 4UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_128SERVICE_DATA: { - // «Service Data - 128 bit UUID» - // Size: 16 or more octets - // The first 16 octets contain the 128 bit Service UUID followed by additional service data - if (record_length < 16) { - ESP_LOGV(TAG, "Record length too small for ESP_BLE_AD_TYPE_128SERVICE_DATA"); - break; - } - ServiceData data{}; - data.uuid = ESPBTUUID::from_raw(record); - data.data.assign(record + 16UL, record + record_length); - this->service_datas_.push_back(data); - break; - } - case ESP_BLE_AD_TYPE_INT_RANGE: - // Avoid logging this as it's very verbose - break; - default: { - ESP_LOGV(TAG, "Unhandled type: advType: 0x%02x", record_type); - break; - } - } - } -} - -std::string ESPBTDevice::address_str() const { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return this->address_str_to(buf); -} - -uint64_t ESPBTDevice::address_uint64() const { return esp32_ble::ble_addr_to_uint64(this->address_); } -#endif // USE_ESP32_BLE_DEVICE - void ESP32BLETracker::dump_config() { ESP_LOGCONFIG(TAG, "BLE Tracker:"); ESP_LOGCONFIG(TAG, @@ -708,6 +434,11 @@ void ESP32BLETracker::dump_config() { " Continuous Scanning: %s", this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f, this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_)); +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + if (this->connection_scan_window_ != 0) { + ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f); + } +#endif ESP_LOGCONFIG(TAG, " Scanner State: %s\n" " Connecting: %d, discovered: %d, disconnecting: %d, active: %d", @@ -721,124 +452,33 @@ void ESP32BLETracker::dump_config() { #ifdef USE_ESP32_BLE_DEVICE void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { - const uint64_t address = device.address_uint64(); - for (auto &disc : this->already_discovered_) { - if (disc == address) - return; - } - this->already_discovered_.push_back(address); - - char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGD(TAG, "Found device %s RSSI=%d", device.address_str_to(addr_buf), device.get_rssi()); - - const char *address_type_s; - switch (device.get_address_type()) { - case BLE_ADDR_TYPE_PUBLIC: - address_type_s = "PUBLIC"; - break; - case BLE_ADDR_TYPE_RANDOM: - address_type_s = "RANDOM"; - break; - case BLE_ADDR_TYPE_RPA_PUBLIC: - address_type_s = "RPA_PUBLIC"; - break; - case BLE_ADDR_TYPE_RPA_RANDOM: - address_type_s = "RPA_RANDOM"; - break; - default: - address_type_s = "UNKNOWN"; - break; - } - - ESP_LOGD(TAG, " Address Type: %s", address_type_s); - if (!device.get_name().empty()) { - ESP_LOGD(TAG, " Name: '%s'", device.get_name().c_str()); - } - for (auto &tx_power : device.get_tx_powers()) { - ESP_LOGD(TAG, " TX Power: %d", tx_power); - } + // Shared implementation in ble_device_base — identical output and per-period + // MAC dedup on every tracker backend. + this->discovered_log_.log_device(TAG, device); } -bool ESPBTDevice::resolve_irk(const uint8_t *irk) const { - static constexpr size_t AES_BLOCK_SIZE = 16; - static constexpr size_t AES_KEY_BITS = 128; - - uint8_t ecb_key[AES_BLOCK_SIZE]; - uint8_t ecb_plaintext[AES_BLOCK_SIZE]; - uint8_t ecb_ciphertext[AES_BLOCK_SIZE]; - - uint64_t addr64 = esp32_ble::ble_addr_to_uint64(this->address_); - - memcpy(&ecb_key, irk, AES_BLOCK_SIZE); - memset(&ecb_plaintext, 0, AES_BLOCK_SIZE); - - ecb_plaintext[13] = (addr64 >> 40) & 0xff; - ecb_plaintext[14] = (addr64 >> 32) & 0xff; - ecb_plaintext[15] = (addr64 >> 24) & 0xff; - -#ifdef USE_BLE_TRACKER_PSA_AES - // Use PSA Crypto API (mbedtls 4.0 / IDF 6.0+) - psa_key_attributes_t attributes = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attributes, PSA_KEY_TYPE_AES); - psa_set_key_bits(&attributes, AES_KEY_BITS); - psa_set_key_usage_flags(&attributes, PSA_KEY_USAGE_ENCRYPT); - psa_set_key_algorithm(&attributes, PSA_ALG_ECB_NO_PADDING); - - mbedtls_svc_key_id_t key_id; - if (psa_import_key(&attributes, ecb_key, AES_BLOCK_SIZE, &key_id) != PSA_SUCCESS) { - return false; - } - - size_t output_length; - psa_status_t status = psa_cipher_encrypt(key_id, PSA_ALG_ECB_NO_PADDING, ecb_plaintext, AES_BLOCK_SIZE, - ecb_ciphertext, AES_BLOCK_SIZE, &output_length); - psa_destroy_key(key_id); - if (status != PSA_SUCCESS || output_length != AES_BLOCK_SIZE) { - return false; - } -#else - // Use legacy mbedtls AES API (IDF < 6.0) - mbedtls_aes_context ctx = {0, 0, {0}}; - mbedtls_aes_init(&ctx); - - if (mbedtls_aes_setkey_enc(&ctx, ecb_key, AES_KEY_BITS) != 0) { - mbedtls_aes_free(&ctx); - return false; - } - - if (mbedtls_aes_crypt_ecb(&ctx, ESP_AES_ENCRYPT, ecb_plaintext, ecb_ciphertext) != 0) { - mbedtls_aes_free(&ctx); - return false; - } - - mbedtls_aes_free(&ctx); -#endif - - return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) && - ecb_ciphertext[13] == ((addr64 >> 16) & 0xff); -} +// resolve_irk() is provided by ble_device_base (portable software AES). #endif // USE_ESP32_BLE_DEVICE void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { - // Process raw advertisements - if (this->raw_advertisements_) { -#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT - for (auto *listener : this->listeners_) { - listener->parse_devices(&scan_result, 1); - } -#endif -#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT - for (auto *client : this->clients_) { - client->parse_devices(&scan_result, 1); - } -#endif + // Neutral raw-advertisement subscriber (the bluetooth_proxy path). + if (this->raw_advertisement_callback_.is_set()) { + ble_device_base::RawAdvertisement adv; + adv.address = esp32_ble::ble_addr_to_uint64(scan_result.bda); + adv.data = scan_result.ble_adv; + adv.data_len = static_cast(scan_result.adv_data_len) + scan_result.scan_rsp_len; + adv.rssi = scan_result.rssi; + adv.addr_type = scan_result.ble_addr_type; + this->raw_advertisement_callback_.invoke(adv); } // Process parsed advertisements if (this->parse_advertisements_) { #ifdef USE_ESP32_BLE_DEVICE ESPBTDevice device; + // The historical ingest keeps the raw scan-result fields populated for + // external components. device.parse_scan_rst(scan_result); bool found = false; @@ -848,6 +488,12 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { found = true; } #endif +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) { + if (listener->parse_device(device)) + found = true; + } +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { @@ -867,17 +513,31 @@ void ESP32BLETracker::process_scan_result_(const BLEScanResult &scan_result) { void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { ESP_LOGV(TAG, "Scan %scomplete, set scanner state to IDLE.", is_stop_complete ? "stop " : ""); #ifdef USE_ESP32_BLE_DEVICE - this->already_discovered_.clear(); + this->discovered_log_.clear(); #endif // Reset timeout state machine instead of cancelling scheduler timeout this->scan_timeout_state_ = ScanTimeoutState::INACTIVE; + this->notify_scan_end_(); + + this->set_scanner_state_(ScannerState::IDLE); +} + +void ESP32BLETracker::notify_scan_end_() { +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + // Window-change restart continues the same scan period; the flag stays set + // across the stop and is cleared by the restart in start_scan_. + if (this->skip_next_scan_end_) + return; +#endif #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_scan_end(); #endif - - this->set_scanner_state_(ScannerState::IDLE); +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + for (auto *listener : this->neutral_listeners_) + listener->on_scan_end(); +#endif } void ESP32BLETracker::handle_scanner_failure_() { @@ -915,6 +575,8 @@ void ESP32BLETracker::try_promote_discovered_clients_() { } ESP_LOGD(TAG, "Promoting client to connect"); + // A connect ends the scan period a window-change restart was continuing. + this->skip_next_scan_end_ = false; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE this->update_coex_preference_(true); #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 78ff60f374..618444e626 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -8,17 +8,9 @@ #include #include #include -#include #ifdef USE_ESP32 -#include -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) -// mbedtls 4.0 (IDF 6.0) removed the legacy mbedtls AES API. -// Use the PSA Crypto API instead. -#define USE_BLE_TRACKER_PSA_AES -#endif - #include #include #include @@ -26,6 +18,9 @@ #include #include +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble/ble_uuid.h" #include "esphome/components/esp32_ble/ble_scan_result.h" @@ -38,118 +33,31 @@ namespace esphome::esp32_ble_tracker { using namespace esp32_ble; -using adv_data_t = std::vector; - -enum AdvertisementParserType { - PARSED_ADVERTISEMENTS, - RAW_ADVERTISEMENTS, -}; +using adv_data_t = ble_device_base::adv_data_t; #ifdef USE_ESP32_BLE_UUID -struct ServiceData { - ESPBTUUID uuid; - adv_data_t data; -}; +using ServiceData = ble_device_base::ServiceData; #endif #ifdef USE_ESP32_BLE_DEVICE -class ESPBLEiBeacon { - public: - ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } - ESPBLEiBeacon(const uint8_t *data); - static optional from_manufacturer_data(const ServiceData &data); - - uint16_t get_major() { return byteswap(this->beacon_data_.major); } - uint16_t get_minor() { return byteswap(this->beacon_data_.minor); } - int8_t get_signal_power() { return this->beacon_data_.signal_power; } - ESPBTUUID get_uuid() { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); } - - protected: - struct { - uint8_t sub_type; - uint8_t length; - uint8_t proximity_uuid[16]; - uint16_t major; - uint16_t minor; - int8_t signal_power; - } PACKED beacon_data_; -}; - -class ESPBTDevice { - public: - void parse_scan_rst(const BLEScanResult &scan_result); - - std::string address_str() const; - - /// Format MAC address into provided buffer, returns pointer to buffer for convenience - const char *address_str_to(std::span buf) const { - format_mac_addr_upper(this->address_, buf.data()); - return buf.data(); - } - - uint64_t address_uint64() const; - - const uint8_t *address() const { return address_; } - - esp_ble_addr_type_t get_address_type() const { return this->address_type_; } - int get_rssi() const { return rssi_; } - const std::string &get_name() const { return this->name_; } - - const std::vector &get_tx_powers() const { return tx_powers_; } - - const optional &get_appearance() const { return appearance_; } - const optional &get_ad_flag() const { return ad_flag_; } - const std::vector &get_service_uuids() const { return service_uuids_; } - - const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } - - const std::vector &get_service_datas() const { return service_datas_; } - - // Exposed through a function for use in lambdas - const BLEScanResult &get_scan_result() const { return *scan_result_; } - - bool resolve_irk(const uint8_t *irk) const; - - optional get_ibeacon() const { - for (auto &it : this->manufacturer_datas_) { - auto res = ESPBLEiBeacon::from_manufacturer_data(it); - if (res.has_value()) - return res; - } - return {}; - } - - protected: - void parse_adv_(const uint8_t *payload, uint8_t len); - - esp_bd_addr_t address_{ - 0, - }; - esp_ble_addr_type_t address_type_{BLE_ADDR_TYPE_PUBLIC}; - int rssi_{0}; - std::string name_{}; - std::vector tx_powers_{}; - optional appearance_{}; - optional ad_flag_{}; - std::vector service_uuids_{}; - std::vector manufacturer_datas_{}; - std::vector service_datas_{}; - const BLEScanResult *scan_result_{nullptr}; -}; +// The advertisement device types are owned by the platform-neutral +// ble_device_base layer; re-exported here (esp32 only) for backward +// compatibility. ESPBTDevice::parse_scan_rst() (esp32-only) adapts BLEScanResult. +using ESPBLEiBeacon = ble_device_base::ESPBLEiBeacon; +using ESPBTDevice = ble_device_base::ESPBTDevice; #endif // USE_ESP32_BLE_DEVICE class ESP32BLETracker; -class ESPBTDeviceListener { +// esp32-flavored listener: the neutral parse_device/on_scan_end come from +// ble_device_base; this subclass adds the esp32-only raw-advertisement path +// (BLEScanResult batches) and the tracker back-pointer. +class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { public: - virtual void on_scan_end() {} -#ifdef USE_ESP32_BLE_DEVICE - virtual bool parse_device(const ESPBTDevice &device) = 0; +#ifndef USE_ESP32_BLE_DEVICE + // Raw-only build: no parsed-device support is compiled in. + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } #endif - virtual bool parse_devices(const BLEScanResult *scan_results, size_t count) { return false; }; - virtual AdvertisementParserType get_advertisement_parser_type() { - return AdvertisementParserType::PARSED_ADVERTISEMENTS; - }; void set_parent(ESP32BLETracker *parent) { parent_ = parent; } protected: @@ -172,60 +80,14 @@ struct ClientStateCounts { bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } }; -enum class ClientState : uint8_t { - // Connection is allocated - INIT, - // Client is disconnecting - DISCONNECTING, - // Connection is idle, no device detected. - IDLE, - // Device advertisement found. - DISCOVERED, - // Connection in progress. - CONNECTING, - // Initial connection established. - CONNECTED, - // The client and sub-clients have completed setup. - ESTABLISHED, -}; +// The client connection state types are owned by the platform-neutral +// ble_device_base layer; re-exported here for backward compatibility. +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; +using ble_device_base::client_state_to_string; -enum class ScannerState { - // Scanner is idle, init state - IDLE, - // Scanner is starting - STARTING, - // Scanner is running - RUNNING, - // Scanner failed to start - FAILED, - // Scanner is stopping - STOPPING, -}; - -/** Listener interface for BLE scanner state changes. - * - * Components can implement this interface to receive scanner state updates - * without the overhead of std::function callbacks. - */ -class BLEScannerStateListener { - public: - virtual void on_scanner_state(ScannerState state) = 0; -}; - -// Helper function to convert ClientState to string -const char *client_state_to_string(ClientState state); - -enum class ConnectionType : uint8_t { - // The default connection type, we hold all the services in ram - // for the duration of the connection. - V1, - // The client has a cache of the services and mtu so we should not - // fetch them again - V3_WITH_CACHE, - // The client does not need the services and mtu once we send them - // so we should wipe them from memory as soon as we send them - V3_WITHOUT_CACHE -}; +// Neutral scanner lifecycle re-exported for backward compatibility. +using ScannerState = ble_device_base::ScannerState; /// Base class for BLE GATT clients that connect to remote devices. /// @@ -242,6 +104,10 @@ enum class ConnectionType : uint8_t { /// The pointer may be null if the client is not registered with a tracker. class ESPBTClient : public ESPBTDeviceListener { public: + /// False keeps the tracker from building parsed ESPBTDevice objects on + /// this client's account (raw consumers use the hub callback). + virtual bool wants_parsed_advertisements() { return true; } + virtual bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) = 0; virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; @@ -294,15 +160,18 @@ 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; } void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; } +#endif void set_scan_active(bool scan_active) { scan_active_ = scan_active; } bool get_scan_active() const { return scan_active_; } void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; } @@ -314,9 +183,32 @@ class ESP32BLETracker : public Component, void loop() override; + // esp32-flavored path (unmigrated esp32 sensors; sets the tracker back-pointer). void register_listener(ESPBTDeviceListener *listener); void register_client(ESPBTClient *client); - void recalculate_advertisement_parser_types(); + + // ---- ble_device_base::BLEHub (the platform-neutral tracker contract) ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->raw_advertisement_callback_ = callback; + } +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) { + this->scanner_state_callback_ = callback; + } +#endif + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // scan_mode_switch is false: the mode is driven through this tracker's own + // API (set_scan_active + restart), not the neutral request_scan_mode(). + return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, + /* scan_mode_switch = */ false}; + } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } + bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } + bool scan_active() { return this->scan_active_; } + // The mode is driven through this tracker's own API (see get_capabilities); + // the neutral request refuses without changing any state. + bool request_scan_mode(bool active) { return false; } #ifdef USE_ESP32_BLE_DEVICE void print_bt_device_info(const ESPBTDevice &device); @@ -334,14 +226,13 @@ class ESP32BLETracker : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif - /// Add a listener for scanner state changes - void add_scanner_state_listener(BLEScannerStateListener *listener) { - this->scanner_state_listeners_.push_back(listener); - } ScannerState get_scanner_state() const { return this->scanner_state_; } protected: - void stop_scan_(); + /// Returns true when a stop was issued to the controller. + bool stop_scan_(); + /// Fire on_scan_end on every listener unless a window-change restart suppressed it. + void notify_scan_end_(); /// Start a single scan by setting up the parameters and doing some esp-idf calls. void start_scan_(bool first); /// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received. @@ -404,10 +295,18 @@ class ESP32BLETracker : public Component, #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - std::vector scanner_state_listeners_; + // Parsed listeners registered through the neutral BLEHub contract (migrated + // sensors); dispatched alongside listeners_. +#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT + StaticVector neutral_listeners_; +#endif + ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + ble_device_base::ScannerStateCallback scanner_state_callback_{}; +#endif #ifdef USE_ESP32_BLE_DEVICE - /// Vector of addresses that have already been printed in print_bt_device_info - std::vector already_discovered_; + /// Per-period "Found device" DEBUG log with MAC dedup (shared ble_device_base impl) + ble_device_base::DiscoveredDeviceLog discovered_log_; #endif // Group 2: Structs (aligned to 4 bytes) @@ -420,6 +319,15 @@ class ESP32BLETracker : public Component, uint32_t scan_duration_; uint32_t scan_interval_; uint32_t scan_window_; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + /// Window used while a GATT connection is active; set by the user, or + /// defaulted when the window was raised to full duty (0 = no fallback). + uint32_t connection_scan_window_{0}; + /// The window to scan at for the given number of active GATT connections. + uint32_t desired_scan_window_(uint8_t active) const { + return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_; + } +#endif esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS}; esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS}; @@ -437,16 +345,20 @@ class ESP32BLETracker : public Component, /// state_version_ to detect if any state changed since last iteration. uint8_t last_processed_version_{0}; ScannerState scanner_state_{ScannerState::IDLE}; - bool scan_continuous_; - bool scan_active_; + // Packed 1-bit flags. + bool scan_continuous_ : 1; + bool scan_active_ : 1; #ifdef USE_OTA_STATE_LISTENER - bool scan_continuous_before_ota_{false}; + bool scan_continuous_before_ota_ : 1 {false}; +#endif + bool ble_was_disabled_ : 1 {true}; + bool parse_advertisements_ : 1 {false}; +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + /// Suppress the window-change restart's on_scan_end sweeps (stop and start). + bool skip_next_scan_end_ : 1 {false}; #endif - bool ble_was_disabled_{true}; - bool raw_advertisements_{false}; - bool parse_advertisements_{false}; #ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE - bool coex_prefer_ble_{false}; + bool coex_prefer_ble_ : 1 {false}; #endif // Scan timeout state machine enum class ScanTimeoutState : uint8_t { @@ -454,10 +366,10 @@ class ESP32BLETracker : public Component, MONITORING, // Actively monitoring for timeout EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot }; + ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; uint32_t scan_start_time_{0}; /// Precomputed timeout value: scan_duration_ * 2000 uint32_t scan_timeout_ms_{0}; - ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE}; }; // NOLINTNEXTLINE diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 9883a0a43e..3c41d22903 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import automation, pins import esphome.codegen as cg @@ -24,6 +25,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.core.entity_helpers import setup_entity +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType @@ -179,7 +181,7 @@ CONF_ON_IMAGE = "on_image" camera_range_param = cv.int_range(min=-2, max=2) -def validate_fb_location_(value): +def validate_fb_location_(value: Any) -> MockObj: validator = cv.enum(ENUM_FB_LOCATION, upper=True) if value.lower() == psram_domain: validator = cv.All(validator, cv.requires_component(psram_domain)) @@ -310,7 +312,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: # Check psram requirement for non-JPEG formats if ( config.get(CONF_PIXEL_FORMAT, "JPEG") != "JPEG" @@ -368,7 +370,7 @@ SETTERS = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_CAMERA") var = cg.new_Pvariable(config[CONF_ID]) await setup_entity(var, config, "camera") @@ -399,7 +401,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.7") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) 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/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index da260ad7a1..d54d5c6937 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE, CONF_PORT from esphome.types import ConfigType @@ -35,12 +36,15 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_MODE): cv.enum(MODES, upper=True), }, ).extend(cv.COMPONENT_SCHEMA), + cv.only_on_esp32, _consume_camera_web_server_sockets, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: server = cg.new_Pvariable(config[CONF_ID]) cg.add(server.set_port(config[CONF_PORT])) cg.add(server.set_mode(config[CONF_MODE])) await cg.register_component(server, config) + # esp_http_server is excluded from IDF builds by default to save compile time + include_builtin_idf_component("esp_http_server") diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 7527bbf7e4..88579e9632 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -13,7 +13,9 @@ namespace esphome::esp32_camera_web_server { -static const int IMAGE_REQUEST_TIMEOUT = 5000; +static const uint32_t IMAGE_REQUEST_TIMEOUT = 5000; +// How often streaming_handler_ reports its throughput. +static const uint32_t STREAM_STATS_INTERVAL = 5000; static const char *const TAG = "esp32_camera_web_server"; #define PART_BOUNDARY "123456789000000000000987654321" @@ -113,10 +115,31 @@ std::shared_ptr CameraWebServer::wait_for_image_() std::shared_ptr image; image.swap(this->image_); - if (!image) { - // retry as we might still be fetching image - xSemaphoreTake(this->semaphore_, IMAGE_REQUEST_TIMEOUT / portTICK_PERIOD_MS); + if (image) + return image; + + // Keep waiting until a frame really shows up, rather than trusting a single + // take() to mean one is there. + // + // on_camera_image() gives the semaphore for every frame it accepts, but the + // swap above hands frames out without taking it, so as soon as the camera is + // faster than this task for one frame the (binary) semaphore is left + // signalled by a frame that has already been consumed. The next take() then + // returns immediately with nothing to swap in, and the caller reports a lost + // frame and closes the stream -- after an arbitrary number of good frames, + // which is exactly when the camera happens to fall behind for one iteration. + // + // running_ is re-checked on every pass so a shutdown or a client that went + // away is noticed straight away instead of after the full timeout. + const uint32_t start = millis(); + while (this->running_) { + uint32_t elapsed = millis() - start; + if (elapsed >= IMAGE_REQUEST_TIMEOUT) + break; + xSemaphoreTake(this->semaphore_, pdMS_TO_TICKS(IMAGE_REQUEST_TIMEOUT - elapsed)); image.swap(this->image_); + if (image) + break; } return image; @@ -170,8 +193,14 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { return res; } - uint32_t last_frame = millis(); uint32_t frames = 0; + // Frame statistics are aggregated over STREAM_STATS_INTERVAL rather than + // logged per frame. A line per frame comes out of this (non-main) task tens + // of times a second, and formatting and buffering it costs more than the + // stream it is reporting on. + uint32_t stats_since = millis(); + uint32_t stats_frames = 0; + uint32_t stats_bytes = 0; camera::Camera::instance()->start_stream(esphome::camera::WEB_REQUESTER); @@ -179,7 +208,10 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { auto image = this->wait_for_image_(); if (!image) { - ESP_LOGW(TAG, "STREAM: failed to acquire frame"); + // A shutdown is not a lost frame: wait_for_image_() returns empty as soon + // as running_ clears, and the loop condition below ends the stream anyway. + if (this->running_) + ESP_LOGW(TAG, "STREAM: failed to acquire frame"); res = ESP_FAIL; } if (res == ESP_OK) { @@ -194,14 +226,29 @@ esp_err_t CameraWebServer::streaming_handler_(struct httpd_req *req) { } if (res == ESP_OK) { frames++; - int64_t frame_time = millis() - last_frame; - last_frame = millis(); - - ESP_LOGD(TAG, "MJPG: %" PRIu32 "B %" PRIu32 "ms (%.1ffps)", (uint32_t) image->get_data_length(), - (uint32_t) frame_time, 1000.0 / (uint32_t) frame_time); + stats_frames++; + stats_bytes += image->get_data_length(); + uint32_t elapsed = millis() - stats_since; + if (elapsed >= STREAM_STATS_INTERVAL) { + ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed, + stats_bytes / stats_frames, stats_frames); + stats_since = millis(); + stats_frames = 0; + stats_bytes = 0; + } } } + // Report whatever did not fill a whole interval, so a stream that only ran for + // a second or two still says what it managed rather than nothing at all. + if (stats_frames > 0) { + uint32_t elapsed = millis() - stats_since; + if (elapsed == 0) + elapsed = 1; + ESP_LOGD(TAG, "MJPG: %.1ffps, %" PRIu32 "B/frame (%" PRIu32 " frames)", stats_frames * 1000.0f / elapsed, + stats_bytes / stats_frames, stats_frames); + } + if (!frames) { res = httpd_send_all(req, STREAM_ERROR, strlen(STREAM_ERROR)); } 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/canbus.py b/esphome/components/esp32_can/canbus.py index 7245ba7513..2272459fb8 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -1,4 +1,5 @@ import math +from typing import Any from esphome import pins import esphome.codegen as cg @@ -26,6 +27,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] DEPENDENCIES = ["esp32"] @@ -88,7 +90,7 @@ CAN_SPEEDS = { } -def validate_bit_rate(value): +def validate_bit_rate(value: Any) -> str: variant = get_esp32_variant() if variant not in CAN_SPEEDS: raise cv.Invalid(f"{variant} is not supported by component {esp32_can_ns}") @@ -112,7 +114,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) -def get_default_tx_enqueue_timeout(bit_rate): +def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length ms_per_packet = bits_per_packet / bit_rate_numeric * 1000 @@ -121,7 +123,7 @@ def get_default_tx_enqueue_timeout(bit_rate): ) # ~10 packet lengths, min 1ms, max 1000ms -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Legacy driver component provides driver/twai.h header include_builtin_idf_component("driver") # Also enable esp_driver_twai for future migration to new API 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_dac/output.py b/esphome/components/esp32_dac/output.py index 7c63d7bd11..c87a9e2a1d 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -9,6 +9,7 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_NUMBER, CONF_PIN +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] @@ -18,7 +19,7 @@ DAC_PINS = { } -def valid_dac_pin(value): +def valid_dac_pin(value: ConfigType) -> ConfigType: variant = get_esp32_variant() try: valid_pins = DAC_PINS[variant] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: include_builtin_idf_component("esp_driver_dac") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index eca7c24b10..ab9455250c 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,6 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -15,8 +16,12 @@ from esphome.const import ( CONF_VARIANT, ) from esphome.cpp_generator import add_define +from esphome.types import ConfigType CODEOWNERS = ["@swoboda1337"] +DEPENDENCIES = ["esp32"] +# 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" @@ -30,7 +35,6 @@ CONF_DATA_READY_PIN = "data_ready_pin" CONF_HANDSHAKE_ACTIVE_HIGH = "handshake_active_high" CONF_HANDSHAKE_PIN = "handshake_pin" CONF_SDIO_FREQUENCY = "sdio_frequency" -CONF_SLOT = "slot" CONF_SPI_MODE = "spi_mode" # Shared fields for both transport modes @@ -39,6 +43,7 @@ BASE_SCHEMA = cv.Schema( cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True), cv.Required(CONF_ACTIVE_HIGH): cv.boolean, cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean, } ) @@ -59,7 +64,7 @@ SDIO_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_sdio(config): +def _validate_sdio(config: ConfigType) -> ConfigType: if config[CONF_BUS_WIDTH] == 4: for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN): if pin not in config: @@ -93,7 +98,7 @@ SPI_SCHEMA = BASE_SCHEMA.extend( ) -def _validate_spi(config): +def _validate_spi(config: ConfigType) -> ConfigType: variant = config[CONF_VARIANT] defaults = _SPI_VARIANT_DEFAULTS.get(variant, _SPI_DEFAULT) @@ -121,7 +126,22 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def _configure_sdio(config): +def _final_validate(config: ConfigType) -> None: + # The esp_hosted releases compatible with older ESP-IDF versions crash at + # boot with a heap double free in the SDIO RX path (fixed in esp_hosted + # 2.11.0, which requires ESP-IDF 5.3), so reject them at validation time. + if (idf_ver := esp32.idf_version()) < cv.Version(5, 3, 0): + raise cv.Invalid( + f"esp32_hosted requires ESP-IDF 5.3 or newer, got {idf_ver}. " + "Remove the framework version from your configuration to use the " + "recommended version, or pin a version at or above 5.3." + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _configure_sdio(config: ConfigType) -> None: slot = config[CONF_SLOT] esp32.add_idf_sdkconfig_option( f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}", @@ -163,7 +183,7 @@ def _configure_sdio(config): ) -def _configure_spi(config): +def _configure_spi(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True) # SPI mode is set via per-variant choice options variant = config[CONF_VARIANT] @@ -211,7 +231,7 @@ def _configure_spi(config): esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: add_define("USE_ESP32_HOSTED") transport = config[CONF_TYPE] transport_prefix = "SDIO" if transport == "sdio" else "SPI" @@ -242,18 +262,20 @@ async def to_code(config): else: _configure_spi(config) - # Library versions + # Place the transport mempool in PSRAM. Required on memory-tight host + # configurations (e.g. P4 with a large LVGL UI) where the internal-RAM + # mempool allocation fails at boot with `sdio_mempool_create` assert. + if config[CONF_USE_PSRAM]: + esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True) + + # Library versions; this component set requires ESP-IDF 5.3 or newer, + # which is enforced at validation time. idf_ver = esp32.idf_version() os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}" - if idf_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1") - esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2") - esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6") - else: - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.0.11") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3") + esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3") + esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.12") esp32.add_extra_script( "post", "esp32_hosted.py", diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index b258a26b08..8e85cce75a 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -3,6 +3,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import esp32, update +from esphome.components.const import CONF_SHA256 import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE from esphome.core import CORE, ID, HexInt @@ -11,7 +12,6 @@ CODEOWNERS = ["@swoboda1337"] AUTO_LOAD = ["sha256", "watchdog", "json"] DEPENDENCIES = ["esp32_hosted"] -CONF_SHA256 = "sha256" CONF_HTTP_REQUEST_ID = "http_request_id" TYPE_EMBEDDED = "embedded" @@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None: return path = CORE.relative_config_path(config[CONF_PATH]) - with open(path, "rb") as f: + with path.open("rb") as f: firmware_data = f.read() calculated = hashlib.sha256(firmware_data).hexdigest() expected = config[CONF_SHA256].lower() @@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None: if config[CONF_TYPE] == TYPE_EMBEDDED: path = config[CONF_PATH] - with open(CORE.relative_config_path(path), "rb") as f: + with CORE.relative_config_path(path).open("rb") as f: firmware_data = f.read() rhs = [HexInt(x) for x in firmware_data] arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index af35d32888..4eb5d1745b 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -56,7 +56,10 @@ static bool parse_version(const std::string &version_str, int &major, int &minor major = minor = patch = 0; const char *ptr = version_str.c_str(); - if (!parse_int(ptr, major) || *ptr++ != '.' || !parse_int(ptr, minor)) + if (!parse_int(ptr, major) || *ptr != '.') + return false; + ++ptr; + if (!parse_int(ptr, minor)) return false; if (*ptr == '.') parse_int(++ptr, patch); @@ -92,7 +95,7 @@ void Esp32HostedUpdate::setup() { if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { // 16 bytes: "255.255.255" (11 chars) + null + safety margin char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1); + snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1); this->update_info_.current_version = buf; } else { this->update_info_.current_version = "unknown"; @@ -120,8 +123,8 @@ void Esp32HostedUpdate::setup() { this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { - ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word, - ESP_APP_DESC_MAGIC_WORD); + ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")", + app_desc->magic_word, static_cast(ESP_APP_DESC_MAGIC_WORD)); this->state_ = update::UPDATE_STATE_NO_UPDATE; } } else { @@ -132,6 +135,10 @@ void Esp32HostedUpdate::setup() { // Publish state this->status_clear_error(); this->publish_state(); + // Defer so the automation runs on the main loop after setup, not during App.setup() + if (this->state_ == update::UPDATE_STATE_AVAILABLE && this->update_available_trigger_) { + this->defer([this]() { this->update_available_trigger_->trigger(this->update_info_); }); + } #else // HTTP mode: check every 10s until network is ready (max 6 attempts) // Only if update interval is > 1 minute to avoid redundant checks @@ -182,6 +189,8 @@ void Esp32HostedUpdate::check() { return; } + const bool was_available = this->state_ == update::UPDATE_STATE_AVAILABLE; + // Compare versions if (this->update_info_.latest_version.empty() || this->update_info_.latest_version == this->update_info_.current_version) { @@ -194,6 +203,9 @@ void Esp32HostedUpdate::check() { this->update_info_.progress = 0.0f; this->status_clear_error(); this->publish_state(); + if (this->state_ == update::UPDATE_STATE_AVAILABLE && !was_available && this->update_available_trigger_) { + this->update_available_trigger_->trigger(this->update_info_); + } #endif } 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/__init__.py b/esphome/components/esp32_improv/__init__.py index ad2f057163..32eb166014 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -4,6 +4,7 @@ from esphome.components import binary_sensor, esp32_ble, improv_base, output from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_server", "improv_base"] CODEOWNERS = ["@jesserockz"] @@ -106,7 +107,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Register the loggers this component needs esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) 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 183820256f..4756fba637 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -1,5 +1,7 @@ #include "esp32_improv_component.h" +#include + #include "esphome/components/bytebuffer/bytebuffer.h" #include "esphome/components/esp32_ble/ble.h" #include "esphome/components/esp32_ble_server/ble_2902.h" @@ -7,6 +9,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 { @@ -15,7 +21,13 @@ using namespace bytebuffer; static const char *const TAG = "esp32_improv.component"; static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; -static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; +static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; +// command + data length + trailing byte +static constexpr size_t RPC_RESPONSE_OVERHEAD = 3; +// Reserves the ESPHOME_MY_LINK entry; a maximal next URL displaces only the +// lower value web server URL +static constexpr size_t MAX_NEXT_URL_LEN = + improv::RPC_RESPONSE_MAX_SIZE - RPC_RESPONSE_OVERHEAD - 1 - sizeof(ESPHOME_MY_LINK); static constexpr uint16_t STOP_ADVERTISING_DELAY = 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds @@ -41,6 +53,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(); } @@ -272,8 +293,9 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::vector &&response) { - this->rpc_response_->set_value(std::move(response)); +void ESP32ImprovComponent::send_response_(std::span response) { + // The BLE characteristic owns its value, so one exact-size copy is required here + this->rpc_response_->set_value(std::vector(response.begin(), response.end())); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } @@ -282,6 +304,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 +369,23 @@ 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. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + this->incoming_data_.clear(); + return; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); @@ -391,40 +439,35 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); - // Build URL list with minimal allocations - // Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL - std::string url_strings[3]; - size_t url_count = 0; + // Build the URL list directly into a stack buffer with no heap allocation + std::array buf; + improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS); #ifdef USE_ESP32_IMPROV_NEXT_URL // Add next_url if configured (should be first per Improv BLE spec) - { - char url_buffer[384]; - size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer)); - if (len > 0) { - url_strings[url_count++] = std::string(url_buffer, len); - } - } + this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif - // Add default URLs for backward compatibility - url_strings[url_count++] = ESPHOME_MY_LINK; + // Add default URLs for backward compatibility; MAX_NEXT_URL_LEN reserves this + // entry's space, so it always fits + builder.add_string(ESPHOME_MY_LINK, sizeof(ESPHOME_MY_LINK) - 1); #ifdef USE_WEBSERVER for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { if (ip.is_ip4()) { - // "http://" (7) + IPv4 max (15) + ":" (1) + port max (5) + null = 29 - char url_buffer[32]; - memcpy(url_buffer, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates - ip.str_to(url_buffer + 7); - size_t len = strlen(url_buffer); - snprintf(url_buffer + len, sizeof(url_buffer) - len, ":%d", USE_WEBSERVER_PORT); - url_strings[url_count++] = url_buffer; + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + ip.str_to(ip_buf); + // "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54 + char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1]; + size_t len = + buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT); + if (!builder.add_string(webserver_url, len)) { + ESP_LOGW(TAG, "Response full; URL dropped"); + } break; } } #endif - this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS, - std::vector(url_strings, url_strings + url_count))); + this->send_response_(builder.finish()); } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) { ESP_LOGD(TAG, "WiFi provisioned externally"); } diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 400006cfb3..414948c977 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -22,6 +22,7 @@ #include "esphome/components/output/binary_output.h" #endif +#include #include #ifdef USE_ESP32 @@ -32,7 +33,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; @@ -109,7 +110,7 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { void set_state_(improv::State state, bool update_advertising = true); void set_error_(improv::Error error); improv::State get_initial_state_() const; - void send_response_(std::vector &&response); + void send_response_(std::span response); void process_incoming_data_(); void on_wifi_connect_timeout_(); void check_wifi_connection_(); diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1076bcabdc..a213a78778 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -1,17 +1,23 @@ +from collections.abc import Callable, Iterable +from typing import Any + from esphome.components import esp32 import esphome.config_validation as cv from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} -def validate_rmt_not_supported(rmt_only_keys): +def validate_rmt_not_supported( + rmt_only_keys: Iterable[str], +) -> Callable[[ConfigType], ConfigType]: """Validate that RMT-only config keys are not used on variants without RMT hardware.""" rmt_only_keys = set(rmt_only_keys) - def _validator(config): + def _validator(config: ConfigType) -> ConfigType: if CORE.is_esp32: variant = esp32.get_esp32_variant() if variant in VARIANTS_NO_RMT: @@ -26,8 +32,8 @@ def validate_rmt_not_supported(rmt_only_keys): return _validator -def validate_clock_resolution(): - def _validator(value): +def validate_clock_resolution() -> Callable[[Any], int]: + def _validator(value: Any) -> int: cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index ed2a8c5a68..7cac1dfb41 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -221,46 +221,12 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { } light::ESPColorView ESP32RMTLEDStripLightOutput::get_view_internal(int32_t index) const { - int32_t r = 0, g = 0, b = 0; - switch (this->rgb_order_) { - case ORDER_RGB: - r = 0; - g = 1; - b = 2; - break; - case ORDER_RBG: - r = 0; - g = 2; - b = 1; - break; - case ORDER_GRB: - r = 1; - g = 0; - b = 2; - break; - case ORDER_GBR: - r = 2; - g = 0; - b = 1; - break; - case ORDER_BGR: - r = 2; - g = 1; - b = 0; - break; - case ORDER_BRG: - r = 1; - g = 2; - b = 0; - break; - } - uint8_t multiplier = this->is_rgbw_ || this->is_wrgb_ ? 4 : 3; - uint8_t white = this->is_wrgb_ ? 0 : 3; - - return {this->buf_ + (index * multiplier) + r + this->is_wrgb_, - this->buf_ + (index * multiplier) + g + this->is_wrgb_, - this->buf_ + (index * multiplier) + b + this->is_wrgb_, - this->is_rgbw_ || this->is_wrgb_ ? this->buf_ + (index * multiplier) + white : nullptr, + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->buf_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, &this->effect_data_[index], &this->correction_}; } @@ -271,35 +237,12 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Pin: %u", this->pin_); ESP_LOGCONFIG(TAG, " RMT Symbols: %" PRIu32, this->rmt_symbols_); - const char *rgb_order; - switch (this->rgb_order_) { - case ORDER_RGB: - rgb_order = "RGB"; - break; - case ORDER_RBG: - rgb_order = "RBG"; - break; - case ORDER_GRB: - rgb_order = "GRB"; - break; - case ORDER_GBR: - rgb_order = "GBR"; - break; - case ORDER_BGR: - rgb_order = "BGR"; - break; - case ORDER_BRG: - rgb_order = "BRG"; - break; - default: - rgb_order = "UNKNOWN"; - break; - } + char channel_colors[5]; ESP_LOGCONFIG(TAG, - " RGB Order: %s\n" + " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_); + this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_.value_or(0), this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 8fb6b63afe..61aac06d76 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" #include "esphome/components/light/light_output.h" #include "esphome/core/color.h" #include "esphome/core/component.h" @@ -15,22 +16,13 @@ namespace esphome::esp32_rmt_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - struct LedParams { rmt_symbol_word_t bit0; rmt_symbol_word_t bit1; 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; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { int32_t size() const override { return this->num_leds_; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); - if (this->is_rgbw_ || this->is_wrgb_) { + if (this->channel_colors_.has_white()) { traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); @@ -50,8 +42,7 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } - void set_is_wrgb(bool is_wrgb) { this->is_wrgb_ = is_wrgb; } + void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -61,7 +52,6 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { void set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, uint32_t bit1_low, uint32_t reset_time_high, uint32_t reset_time_low); - void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } void clear_effect_data() override { @@ -74,7 +64,7 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { protected: light::ESPColorView get_view_internal(int32_t index) const override; - size_t get_buffer_size_() const { return this->num_leds_ * (this->is_rgbw_ || this->is_wrgb_ ? 4 : 3); } + size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } uint8_t *buf_{nullptr}; uint8_t *effect_data_{nullptr}; @@ -89,13 +79,11 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { uint32_t rmt_symbols_{48}; uint8_t pin_; uint16_t num_leds_; - bool is_rgbw_{false}; - bool is_wrgb_{false}; bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - RGBOrder rgb_order_{ORDER_RGB}; + light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 1c6943b003..571b7d93b8 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -import logging from esphome import pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, light -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB, CONF_USE_PSRAM from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -20,8 +19,7 @@ from esphome.const import ( CONF_RMT_SYMBOLS, CONF_USE_DMA, ) - -_LOGGER = logging.getLogger(__name__) +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] @@ -31,17 +29,6 @@ ESP32RMTLEDStripLightOutput = esp32_rmt_led_strip_ns.class_( "ESP32RMTLEDStripLightOutput", light.AddressableLight ) -RGBOrder = esp32_rmt_led_strip_ns.enum("RGBOrder") - -RGB_ORDERS = { - "RGB": RGBOrder.ORDER_RGB, - "RBG": RGBOrder.ORDER_RBG, - "GRB": RGBOrder.ORDER_GRB, - "GBR": RGBOrder.ORDER_GBR, - "BGR": RGBOrder.ORDER_BGR, - "BRG": RGBOrder.ORDER_BRG, -} - @dataclass class LEDStripTimings: @@ -61,7 +48,6 @@ CHIPSETS = { "SM16703": LEDStripTimings(300, 900, 900, 300, 0, 0), } -CONF_IS_WRGB = "is_wrgb" CONF_BIT0_HIGH = "bit0_high" CONF_BIT0_LOW = "bit0_low" CONF_BIT1_HIGH = "bit1_high" @@ -80,7 +66,11 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(ESP32RMTLEDStripLightOutput), cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema, cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, - cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Optional(CONF_CHANNEL_COLORS): light.validate_channel_colors, + # Deprecated in favour of CONF_CHANNEL_COLORS, remove in 2027.3.0 + cv.Optional(CONF_RGB_ORDER): cv.one_of(*light.RGB_ORDERS, upper=True), + cv.Optional(CONF_IS_RGBW): cv.boolean, + cv.Optional(CONF_IS_WRGB): cv.boolean, cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, @@ -94,8 +84,6 @@ CONFIG_SCHEMA = cv.All( ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), - cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, - cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] @@ -130,10 +118,13 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), + light.migrate_channel_colors( + removed_in="2027.3.0", component="esp32_rmt_led_strip" + ), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) include_builtin_idf_component("esp_driver_rmt") @@ -173,9 +164,9 @@ async def to_code(config): ) ) - cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) - cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) - cg.add(var.set_is_wrgb(config[CONF_IS_WRGB])) + cg.add( + var.set_channel_colors(light.channel_colors_struct(config[CONF_CHANNEL_COLORS])) + ) cg.add(var.set_use_psram(config[CONF_USE_PSRAM])) cg.add(var.set_rmt_symbols(config[CONF_RMT_SYMBOLS])) if CONF_USE_DMA in config: diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index 10ad339b12..ede6beb9b6 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable, Iterable import logging +from typing import Any import esphome.codegen as cg from esphome.components import esp32 @@ -23,6 +25,7 @@ from esphome.const import ( CONF_VOLTAGE_ATTENUATION, ) from esphome.core import TimePeriod +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -181,7 +184,7 @@ EFFECTIVE_HIGH_VOLTAGE = { } -def validate_touch_pad(value): +def validate_touch_pad(value: Any) -> int: value = gpio.gpio_pin_number_validator(value) variant = get_esp32_variant() pads = TOUCH_PADS.get(variant) @@ -192,7 +195,7 @@ def validate_touch_pad(value): return pads[value] # Return integer channel ID -def validate_variant_vars(config): +def validate_variant_vars(config: ConfigType) -> ConfigType: variant = get_esp32_variant() invalid_vars = set() if variant == VARIANT_ESP32: @@ -219,8 +222,8 @@ def validate_variant_vars(config): return config -def validate_voltage(values): - def validator(value): +def validate_voltage(values: Iterable[str]) -> Callable[[Any], str]: + def validator(value: Any) -> str: if isinstance(value, float) and value.is_integer(): value = int(value) value = cv.string(value) @@ -300,7 +303,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # New unified touch sensor driver include_builtin_idf_component("esp_driver_touch_sens") diff --git a/esphome/components/esp32_touch/binary_sensor.py b/esphome/components/esp32_touch/binary_sensor.py index 75560d71b1..2489c2abc1 100644 --- a/esphome/components/esp32_touch/binary_sensor.py +++ b/esphome/components/esp32_touch/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType from . import ESP32TouchComponent, esp32_touch_ns, validate_touch_pad @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(ESP32TouchBinarySensor).exten ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_ESP32_TOUCH_ID]) var = cg.new_Pvariable( config[CONF_ID], 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 38df282fb9..63665e7681 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,7 +1,9 @@ import logging from pathlib import Path +import platform import re import subprocess +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -20,12 +22,20 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import ( + CORE, + CoroPriority, + EsphomeError, + Lambda, + coroutine_with_priority, +) from esphome.core.config import BOARD_MAX_LENGTH -from esphome.helpers import copy_file_if_changed +from esphome.helpers import IS_MACOS, copy_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script +from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_LD_SCRIPTS +from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script from .const import ( CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, @@ -34,6 +44,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE, + KEY_LDSCRIPT, KEY_PIN_INITIAL_STATES, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, @@ -80,7 +91,7 @@ def lambdas_use_scanf_float(config: ConfigType) -> bool: return False -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino" @@ -94,19 +105,20 @@ def set_core_data(config): return config -def get_download_types(storage_json): +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """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. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] return [ { "title": "Standard format", @@ -125,7 +137,16 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" if ver <= cv.Version(2, 6, 2): return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" - return f"~3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + # Same encoding the native toolchain uses for its package download, so a + # version bump cannot drift between the two paths. + from esphome.arduino8266.framework import framework_package_version + + try: + return f"~{framework_package_version(ver)}" + except EsphomeError as err: + # Anchor the 4.x rejection to the framework version line instead of + # aborting with a bare traceback-level error + raise cv.Invalid(str(err), path=[CONF_VERSION]) from err # NOTE: Keep this in mind when updating the recommended version: @@ -133,7 +154,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 @@ -149,7 +169,7 @@ ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1) -def _arduino_check_versions(value): +def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { "dev": (cv.Version(3, 1, 2), "https://github.com/esp8266/Arduino.git"), @@ -192,7 +212,7 @@ def _arduino_check_versions(value): return value -def _parse_platform_version(value): +def _parse_platform_version(value: Any) -> str: try: # if platform version is a valid version constraint, prefix the default package cv.platformio_version_constraint(value) @@ -205,8 +225,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, @@ -232,12 +256,66 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), + # Until the native toolchain lands, PlatformIO is the only backend; + # reject a --toolchain this platform cannot serve yet. + cv.require_platformio_toolchain("ESP8266"), set_core_data, ) +def check_rosetta() -> None: + """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. + + There is no native arm64 build of the xtensa-lx106 toolchain; on Apple + Silicon it runs under Rosetta 2, which macOS updates can remove. + """ + if not IS_MACOS or platform.machine() != "arm64": + return + try: + result = subprocess.run( + ["/usr/bin/arch", "-x86_64", "/usr/bin/true"], + capture_output=True, + close_fds=False, + check=False, + ) + except OSError: + return # arch(1) unavailable; let the build proceed + if result.returncode != 0: + raise EsphomeError( + "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " + "compiler that requires Rosetta 2, which is not installed on " + "this system. Install it with:\n" + " softwareupdate --install-rosetta --agree-to-license" + ) + + +def _choose_ld_script(board: str, ver: cv.Version) -> str | None: + """The flash ld to pin for this board and core, or None for cores + without ld-script support.""" + board_data = BOARDS[board] + ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]] + if ver <= cv.Version(2, 3, 0): + # No ld script support + return None + if ver <= cv.Version(2, 4, 2): + # Old ld script path; the modern per-board override names do not + # exist in this core's SDK, so the override cannot be honored. + # Substituting the size default would move _FS_end and the + # preferences sector, wiping flash-backed state on flash. + if KEY_LDSCRIPT in board_data: + raise EsphomeError( + f"Board {board} requires its {board_data[KEY_LDSCRIPT]} " + f"flash layout, which Arduino core {ver} cannot honor; " + "use a core newer than 2.4.2" + ) + return ld_scripts[0] + # A per-board override preserves a layout the board shipped with + # (see d1_wroom_02 in boards.py) + return board_ld_script(board_data) + + @coroutine_with_priority(CoroPriority.PLATFORM) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add(esp8266_ns.setup_preferences()) cg.add_platformio_option("lib_ldf_mode", "off") @@ -260,9 +338,11 @@ async def to_code(config): ) extra_scripts = [ + "pre:ccache.py", "pre:testing_mode.py", "pre:exclude_updater.py", "pre:exclude_waveform.py", + "pre:relocate_ratetable.py", ] if not enable_scanf_float: extra_scripts.append("pre:remove_float_scanf.py") @@ -313,6 +393,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 +415,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. @@ -341,17 +435,7 @@ async def to_code(config): ) if config[CONF_BOARD] in BOARDS: - flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE] - ld_scripts = ESP8266_LD_SCRIPTS[flash_size] - - if ver <= cv.Version(2, 3, 0): - # No ld script support - ld_script = None - elif ver <= cv.Version(2, 4, 2): - # Old ld script path - ld_script = ld_scripts[0] - else: - ld_script = ld_scripts[1] + ld_script = _choose_ld_script(config[CONF_BOARD], ver) if ld_script is not None: cg.add_platformio_option("board_build.ldscript", ld_script) @@ -395,31 +479,19 @@ async def finalize_serial_config() -> None: # Called by writer.py def copy_files() -> None: dir = Path(__file__).parent - post_build_file = dir / "post_build.py.script" - copy_file_if_changed( - post_build_file, - CORE.relative_build_path("post_build.py"), - ) - testing_mode_file = dir / "testing_mode.py.script" - copy_file_if_changed( - testing_mode_file, - CORE.relative_build_path("testing_mode.py"), - ) - exclude_updater_file = dir / "exclude_updater.py.script" - copy_file_if_changed( - exclude_updater_file, - CORE.relative_build_path("exclude_updater.py"), - ) - exclude_waveform_file = dir / "exclude_waveform.py.script" - copy_file_if_changed( - exclude_waveform_file, - CORE.relative_build_path("exclude_waveform.py"), - ) - remove_float_scanf_file = dir / "remove_float_scanf.py.script" - copy_file_if_changed( - remove_float_scanf_file, - CORE.relative_build_path("remove_float_scanf.py"), - ) + for script in ( + "post_build", + "testing_mode", + "exclude_updater", + "exclude_waveform", + "remove_float_scanf", + "relocate_ratetable", + ): + copy_file_if_changed( + dir / f"{script}.py.script", + CORE.relative_build_path(f"{script}.py"), + ) + copy_ccache_script() # ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder @@ -462,7 +534,7 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config, addr): +def _decode_pc(config: ConfigType, addr: str) -> None: from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) @@ -472,7 +544,7 @@ def _decode_pc(config, addr): command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # pylint: disable=broad-except + except Exception: # noqa: BLE001 # pylint: disable=broad-except _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return @@ -483,7 +555,7 @@ def _decode_pc(config, addr): _LOGGER.warning("Decoded %s", translation) -def _parse_register(config, regex, line): +def _parse_register(config: ConfigType, regex: re.Pattern[str], line: str) -> None: match = regex.match(line) if match is not None: _decode_pc(config, match.group(1)) @@ -492,13 +564,22 @@ def _parse_register(config, regex, line): STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):") STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})") STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})") +# Structured crash handler output (crash_handler.cpp) from a previous boot: +# PC: 0x40220060 +# EXCVADDR: 0x0000008A +# BT0: 0x40212345 +STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})") +STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile( + r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})" +) +STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})") STACKTRACE_BAD_ALLOC_RE = re.compile( r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$" ) STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}") -def process_stacktrace(config, line, backtrace_state): +def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool: line = line.strip() # ESP8266 Exception type match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line) @@ -508,10 +589,17 @@ def process_stacktrace(config, line, backtrace_state): "Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown") ) - # ESP8266 PC/EXCVADDR + # ESP8266 PC/EXCVADDR (legacy Arduino postmortem) _parse_register(config, STACKTRACE_ESP8266_PC_RE, line) _parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line) + # ESP8266 structured crash handler (crash_handler.cpp) from previous boot + _parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line) + _parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line) + match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line) + if match is not None: + _decode_pc(config, match.group(1)) + # bad alloc match = re.match(STACKTRACE_BAD_ALLOC_RE, line) if match is not None: diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 02bfa9e662..268c6b50aa 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -1,3 +1,5 @@ +from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT + FLASH_SIZE_1_MB = 2**20 FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2 FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB @@ -164,7 +166,8 @@ ESP8266_BOARD_PINS = { } """ -BOARDS generate with: +BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as +d1_wroom_02; the recipe emits only name/flash_size): git clone https://github.com/platformio/platform-espressif8266 for x in platform-espressif8266/boards/*.json; do @@ -182,6 +185,19 @@ for x in platform-espressif8266/boards/*.json; do done | sort """ + +def board_ld_script(board_data: dict) -> str: + """The modern (core > 2.4.2) flash linker script for a board: its + shipped-layout override, else the size default (the no-FS layout). + + Single source of truth for the PlatformIO pinning in __init__ and the + native generator's fallback, so the per-board rule cannot drift. + """ + return board_data.get( + KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1] + ) + + BOARDS = { "agruminolemon": { "name": "Lifely Agrumino Lemon v4", @@ -199,6 +215,15 @@ BOARDS = { "name": "WeMos D1 mini Pro", "flash_size": FLASH_SIZE_16_MB, }, + "d1_wroom_02": { + "name": "WeMos D1 ESP-WROOM-02", + "flash_size": FLASH_SIZE_2_MB, + # This board joined BOARDS after shipping with the manifest default + # (64 KB filesystem region); the flash-size default (2m.ld) would + # move _FS_end and with it the preferences sector, wiping existing + # devices' flash-backed state on update. + KEY_LDSCRIPT: "eagle.flash.2m64.ld", + }, "d1": { "name": "WEMOS D1 R1", "flash_size": FLASH_SIZE_4_MB, @@ -360,3 +385,112 @@ BOARDS = { "flash_size": FLASH_SIZE_4_MB, }, } + + +# Per-board variant dir + identity defines from platform-espressif8266 4.x +# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added +# by the generator. +# +# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the +# native toolchain mirrors; regenerate against the tag when bumping it): +# +# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 +# python3 - <<'EOF' +# import json, glob, os +# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): +# b = json.load(open(f))["build"] +# extra = b["extra_flags"] +# extra = extra.split() if isinstance(extra, str) else extra +# defines = [ +# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") +# ] +# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") +# board = os.path.splitext(os.path.basename(f))[0] +# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +# EOF +ESP8266_BOARD_BUILD = { + "agruminolemon": { + "variant": "agruminolemonv4", + "defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",), + }, + "d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)}, + "d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)}, + "d1_mini_lite": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",), + }, + "d1_mini_pro": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",), + }, + "d1_wroom_02": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",), + }, + "eduinowifi": { + "variant": "eduinowifi", + "defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",), + }, + "esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)}, + "esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp_wroom_02": { + "variant": "nodemcu", + "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",), + }, + "espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)}, + "espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espmxdevkit": { + "variant": "esp8285", + "defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"), + }, + "espresso_lite_v1": { + "variant": "espresso_lite_v1", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",), + }, + "espresso_lite_v2": { + "variant": "espresso_lite_v2", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",), + }, + "gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)}, + "heltec_wifi_kit_8": { + "variant": "wifi_kit_8", + "defines": ("ARDUINO_wifi_kit_8",), + }, + "huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)}, + "inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)}, + "modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)}, + "nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)}, + "nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)}, + "oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)}, + "phoenix_v1": { + "variant": "phoenix_v1", + "defines": ("ARDUINO_ESP8266_PHOENIX_V1",), + }, + "phoenix_v2": { + "variant": "phoenix_v2", + "defines": ("ARDUINO_ESP8266_PHOENIX_V2",), + }, + "sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)}, + "sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)}, + "sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)}, + "sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)}, + "sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)}, + "wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)}, + "wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)}, + "wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)}, + "wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)}, + "wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)}, + "xinabox_cw01": { + "variant": "xinabox", + "defines": ("ARDUINO_ESP8266_XINABOX_CW01",), + }, +} diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py new file mode 100644 index 0000000000..eb6ed1b91b --- /dev/null +++ b/esphome/components/esp8266/build_surgery.py @@ -0,0 +1,123 @@ +"""Linker-script surgery shared with the native (PlatformIO-free) toolchain. + +These mirror the PlatformIO extra scripts in this directory +(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run +inside SCons and must stay self-contained. The native build generator applies +the same patches to the linker scripts it generates, so the logic lives here +as plain functions. Keep both in sync when changing either. +``segment_length`` is native-toolchain-only and has no script twin. +""" + +from __future__ import annotations + +from collections.abc import Collection +import hashlib +import re + +# Move the NONOS SDK wifi rate tables from flash to DRAM; see +# relocate_ratetable.py.script for the full background (NONOS SDK issue 320). +RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +_RATETABLE_COMMENT = ( + "/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" +) +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + +# Memory sizes for testing mode (allow larger builds for CI component grouping) +TESTING_IRAM_SIZE = "0x200000" # 2MB +TESTING_DRAM_SIZE = "0x200000" # 2MB +TESTING_FLASH_SIZE = "0x2000000" # 32MB + + +def relocate_ratetable(content: str) -> str: + """Insert the rate-table DRAM rule into a generated common linker script.""" + if RATETABLE_RULE in content: + return content + match = _RATETABLE_ANCHOR.search(content) + if match is None: + raise RuntimeError( + "'_data_start' anchor not found in the generated linker script; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + insert_pos = match.end() + return ( + content[:insert_pos] + + f"\n {_RATETABLE_COMMENT}" + + f"\n {RATETABLE_RULE}" + + content[insert_pos:] + ) + + +_TESTING_SEGMENT_SIZES = { + "iram1_0_seg": TESTING_IRAM_SIZE, + "dram0_0_seg": TESTING_DRAM_SIZE, + "irom0_0_seg": TESTING_FLASH_SIZE, +} + + +def _segment_line_re(segment_name: str) -> re.Pattern[str]: + """The MEMORY line for one segment: `` : org = 0x..., len = 0x...``. + + Anchored to the start of the line so a name never matches inside a + longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size + group stops at the hex digits, leaving any ``ul`` suffix (from the + preprocessed ``MMU_IRAM_SIZE``) in place. + """ + return re.compile( + rf"(^[ \t]*{re.escape(segment_name)}" + r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" + r"(0x[0-9a-fA-F]+)", + re.MULTILINE, + ) + + +def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str: + """Enlarge the named memory segments so grouped CI test builds can link. + + Each caller passes the segments its linker script defines: the + generated common ld carries ``iram1_0_seg``; the flash ld carries + ``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match + raises, since a silently kept real memory limit would fail grouped + builds far from the cause. + """ + for segment in _TESTING_SEGMENT_SIZES: + if segment not in segments and _segment_line_re(segment).search(content): + raise RuntimeError( + f"Testing-mode segment {segment} is present in the linker " + "script but was not selected for patching" + ) + for segment in segments: + if segment not in _TESTING_SEGMENT_SIZES: + raise RuntimeError(f"Unknown testing-mode segment {segment!r}") + content, count = _segment_line_re(segment).subn( + rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content + ) + if count == 0: + raise RuntimeError( + f"Testing-mode memory patch failed: segment {segment} " + "not found (has the Arduino core linker script changed?)" + ) + return content + + +def segment_length(content: str, segment_name: str) -> int | None: + """Read a memory segment's length from linker script content. + + Returns None for an absent segment OR an unparsable line; callers must + treat None as "no usable budget" and warn (as the Flash summary does), + never as "no limit". + """ + match = _segment_line_re(segment_name).search(content) + return int(match.group(2), 16) if match else None + + +def surgery_fingerprint() -> str: + """Hash of this module's source; linker-script caches include it so an + edit here invalidates them.""" + import inspect + import sys + + source = inspect.getsource(sys.modules[__name__]) + return hashlib.sha256(source.encode()).hexdigest() diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 229ac61f24..50f103ed2d 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,4 +1,8 @@ import esphome.codegen as cg + +# Re-exported from the shared definition; here it indexes the BOARDS +# metadata dicts, whose entries in boards.py spell the literal. +from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import from esphome.core import CORE KEY_ESP8266 = "esp8266" @@ -8,10 +12,14 @@ CONF_RESTORE_FROM_FLASH = "restore_from_flash" CONF_EARLY_PIN_INIT = "early_pin_init" CONF_ENABLE_SERIAL = "enable_serial" CONF_ENABLE_SERIAL1 = "enable_serial1" -KEY_FLASH_SIZE = "flash_size" KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" +# Set for the native (non-PlatformIO) toolchain's build generator +KEY_FLASH_MODE = "flash_mode" +KEY_SCANF_FLOAT = "scanf_float" +# Per-board flash-layout override consumed by board_ld_script() +KEY_LDSCRIPT = "ldscript" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 91b0cf9082..dc79043f21 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -118,8 +118,6 @@ static const LogString *get_exception_cause(uint32_t cause) { } static const LogString *get_reset_reason(uint32_t reason) { - if (reason == REASON_WDT_RST) - return LOG_STR("Hardware WDT"); if (reason == REASON_EXCEPTION_RST) return LOG_STR("Exception"); if (reason == REASON_SOFT_WDT_RST) @@ -162,13 +160,20 @@ void crash_handler_log() { if (!is_crash_reason(resetInfo.reason)) return; + ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); + if (resetInfo.reason == REASON_WDT_RST) { + // A hardware WDT reset happens entirely in hardware: the postmortem hook + // never runs, so rst_info epc1/exccause and the RTC backtrace are + // leftovers from an earlier crash. Don't misattribute them (#18596). + ESP_LOGE(TAG, " Reason: Hardware WDT (no crash state is recorded for hardware WDT resets)"); + return; + } + // Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost). // Both resetInfo and RTC data survive until the next reset, so this can be // called multiple times (logger init + API subscribe) with the same result. uint32_t backtrace[MAX_BACKTRACE]; uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE); - - ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***"); // GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific // ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match // the Arduino core's postmortem handler behavior. 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/gpio.py b/esphome/components/esp8266/gpio.py index 64be4a6495..356af6e006 100644 --- a/esphome/components/esp8266/gpio.py +++ b/esphome/components/esp8266/gpio.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -18,6 +19,8 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import boards from .const import KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, esp8266_ns @@ -27,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) ESP8266GPIOPin = esp8266_ns.class_("ESP8266GPIOPin", cg.InternalGPIOPin) -def _lookup_pin(value): +def _lookup_pin(value: str) -> int: board = CORE.data[KEY_ESP8266][KEY_BOARD] board_pins = boards.ESP8266_BOARD_PINS.get(board, {}) @@ -42,7 +45,7 @@ def _lookup_pin(value): raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.") -def _translate_pin(value): +def _translate_pin(value: Any) -> int: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -69,7 +72,7 @@ _ESP_SDIO_PINS = { } -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int: value = _translate_pin(value) if value < 0 or value > 17: raise cv.Invalid(f"ESP8266: Invalid pin number: {value}") @@ -86,7 +89,7 @@ def validate_gpio_pin(value): return value -def validate_supports(value): +def validate_supports(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] mode = value[CONF_MODE] is_input = mode[CONF_INPUT] @@ -160,7 +163,7 @@ class PinInitialState: @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP8266, ESP8266_PIN_SCHEMA) -async def esp8266_pin_to_code(config): +async def esp8266_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] mode = config[CONF_MODE] @@ -192,7 +195,7 @@ async def esp8266_pin_to_code(config): @coroutine_with_priority(CoroPriority.WORKAROUNDS) -async def add_pin_initial_states_array(): +async def add_pin_initial_states_array() -> None: # Add includes at the very end, so that they override everything initial_states: list[PinInitialState] = CORE.data[KEY_ESP8266][ KEY_PIN_INITIAL_STATES diff --git a/esphome/components/esp8266/hal.cpp b/esphome/components/esp8266/hal.cpp index e8f472dc8a..3501c51859 100644 --- a/esphome/components/esp8266/hal.cpp +++ b/esphome/components/esp8266/hal.cpp @@ -5,6 +5,7 @@ #include #include +#include extern "C" { #include @@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() { return result; } -// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object -// call to the original millis() that --wrap can't intercept, so calling ::delay() -// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still -// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and -// WiFi run correctly. Theoretically less power-efficient than Arduino's -// os_timer-based delay() for long waits, but nearly all ESPHome delays are short -// (sensor/I²C/SPI settling in the 1–100 ms range) where the difference is -// negligible. +// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to +// suspend the cont task for `ms` milliseconds without polling millis(). This +// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay()) +// and lets the SDK run freely while we wait, which timing-sensitive +// interrupt-driven code (e.g. ESP8266 software-serial RX in components like +// fingerprint_grow) depends on. The poll-based busy-wait that this replaced +// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and +// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does +// not call millis(), so the slow Arduino millis() body is not pulled into IRAM +// by this path (the --wrap=millis goal of #15662 is preserved). void HOT delay(uint32_t ms) { if (ms == 0) { optimistic_yield(1000); return; } - uint32_t start = millis(); - while (millis() - start < ms) { - optimistic_yield(1000); - } + esp_delay(ms); } void arch_restart() { diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index effa9c9371..f3b33da692 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) { return pgm_read_word(addr); // NOLINT } +// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers +// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on +// ESP8266 is ~4x as many flash accesses as the bulk path. +__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) { + memcpy_P(dst, src, len); // NOLINT +} // NOLINTNEXTLINE(readability-identifier-naming) __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); } 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/relocate_ratetable.py.script b/esphome/components/esp8266/relocate_ratetable.py.script new file mode 100644 index 0000000000..c9d0ba166d --- /dev/null +++ b/esphome/components/esp8266/relocate_ratetable.py.script @@ -0,0 +1,70 @@ +# pylint: disable=E0602 +Import("env") # noqa + +# Move the NONOS SDK wifi rate tables from flash to DRAM +# +# libnet80211.a ships its 802.11b/11g rate tables in the .irom.text section +# of ieee80211_phy.o (440 bytes of pure data, no relocations). The Arduino +# core linker script places .irom.text in flash, but the SDK reads these +# tables with byte loads and ets_memcpy from the wifi RX path while parsing +# beacons. Byte access to flash-mapped memory from that context misbehaves +# and crashes with StoreProhibited in ROM memcpy (PC 0x4000df64): +# +# scan_parse_beacon -> cnx_update_bss_more -> ieee80211_phy_init +# -> ieee80211_setup_ratetable -> ets_memcpy -> crash +# +# See https://github.com/espressif/ESP8266_NONOS_SDK/issues/320 (1000+ +# reports). The SDK is abandoned so the fix from +# https://github.com/espressif/ESP8266_NONOS_SDK/pull/345 was never merged; +# we apply the same linker rule here: place ieee80211_phy.o's .irom.text +# inside the DRAM .data output section so the tables are copied to RAM at +# boot. Costs 440 bytes of DRAM. +# +# The rule is inserted into the working linker script that PlatformIO +# generates in the build directory (local.eagle.app.v6.common.ld). SDK +# package files are never modified. + +import re +from os.path import join + +RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + + +def relocate_ratetable(source, target, env): + """Insert the rate table DRAM rule into the generated linker script. + + Runs as a pre-action of the link step; the linker script is a declared + dependency of the elf, so it has already been generated at this point. + """ + ld_path = join(env.subst("$BUILD_DIR"), "ld", "local.eagle.app.v6.common.ld") + with open(ld_path, encoding="utf-8") as f: + contents = f.read() + + if RULE in contents: + return # Already patched (incremental build) + + match = ANCHOR.search(contents) + if match is None: + raise RuntimeError( + f"ESPHome: '_data_start' anchor not found in {ld_path}; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + + insert_pos = match.end() + patched = ( + contents[:insert_pos] + + "\n /* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" + + f"\n {RULE}" + + contents[insert_pos:] + ) + with open(ld_path, "w", encoding="utf-8") as f: + f.write(patched) + print("ESPHome: Relocated wifi rate tables to DRAM (fixes beacon parse crash)") + + +# Register the callback to run before the link step +env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", relocate_ratetable) 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/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index f119a6ba9f..dd151a3e04 100644 --- a/esphome/components/esp8266_pwm/output.py +++ b/esphome/components/esp8266_pwm/output.py @@ -4,11 +4,14 @@ from esphome.components import output from esphome.components.esp8266.const import require_waveform import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NUMBER, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp8266"] -def valid_pwm_pin(value): +def valid_pwm_pin(value: ConfigType) -> ConfigType: num = value[CONF_NUMBER] cv.one_of(0, 1, 2, 3, 4, 5, 9, 10, 12, 13, 14, 15, 16)(num) return value @@ -35,7 +38,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config) -> None: +async def to_code(config: ConfigType) -> None: require_waveform() var = cg.new_Pvariable(config[CONF_ID]) @@ -59,7 +62,12 @@ async def to_code(config) -> None: ), synchronous=True, ) -async def esp8266_set_frequency_to_code(config, action_id, template_arg, args): +async def esp8266_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/esp_ldo/__init__.py b/esphome/components/esp_ldo/__init__.py index a489651b59..46810d422d 100644 --- a/esphome/components/esp_ldo/__init__.py +++ b/esphome/components/esp_ldo/__init__.py @@ -1,9 +1,14 @@ +from typing import Any + from esphome.automation import Action, register_action import esphome.codegen as cg from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_VOLTAGE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -22,7 +27,7 @@ CONF_PASSTHROUGH = "passthrough" adjusted_ids = set() -def validate_ldo_voltage(value): +def validate_ldo_voltage(value: Any) -> str | float: if isinstance(value, str) and value.lower() == CONF_PASSTHROUGH: return CONF_PASSTHROUGH value = cv.voltage(value) @@ -33,7 +38,7 @@ def validate_ldo_voltage(value): ) -def validate_ldo_config(config): +def validate_ldo_config(config: ConfigType) -> ConfigType: channel = config[CONF_CHANNEL] allow_internal = config[CONF_ALLOW_INTERNAL_CHANNEL] if allow_internal and channel not in CHANNELS_INTERNAL: @@ -77,7 +82,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(configs): +async def to_code(configs: list[ConfigType]) -> None: for config in configs: var = cg.new_Pvariable(config[CONF_ID], config[CONF_CHANNEL]) await cg.register_component(var, config) @@ -89,7 +94,7 @@ async def to_code(configs): cg.add(var.set_adjustable(config[CONF_ADJUSTABLE])) -def final_validate(configs): +def final_validate(configs: list[ConfigType]) -> None: for channel in CHANNELS: used = [config for config in configs if config[CONF_CHANNEL] == channel] if len(used) > 1: @@ -112,7 +117,7 @@ def final_validate(configs): FINAL_VALIDATE_SCHEMA = final_validate -def adjusted_ldo_id(value): +def adjusted_ldo_id(value: Any) -> ID: value = cv.use_id(EspLdo)(value) adjusted_ids.add(value) return value @@ -131,7 +136,12 @@ def adjusted_ldo_id(value): ), synchronous=True, ) -async def ldo_voltage_adjust_to_code(config, action_id, template_arg, args): +async def ldo_voltage_adjust_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: parent = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, parent) template_ = await cg.templatable(config[CONF_VOLTAGE], args, cg.float_) 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 f7793b1493..3ef4c7ba13 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -37,7 +37,7 @@ esphome = cg.esphome_ns.namespace("esphome") ESPHomeOTAComponent = esphome.class_("ESPHomeOTAComponent", OTAComponent) -def ota_esphome_final_validate(config): +def ota_esphome_final_validate(config: ConfigType) -> None: full_conf = fv.full_config.get() full_ota_conf = full_conf[CONF_OTA] new_ota_conf = [] @@ -126,14 +126,14 @@ CONFIG_SCHEMA = cv.All( CONF_PORT, esp8266=8266, esp32=3232, - rp2040=2040, + rp2=2040, bk72xx=8892, ln882x=8820, rtl87xx=8892, host=8082, ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(), cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid( f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode" ), diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index f1857ed664..74f84b71fb 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"); @@ -108,8 +109,8 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, " Partition access allowed\n" " Running app:\n" - " Partition address: 0x%X\n" - " Used size: %zu bytes (0x%X)", + " Partition address: 0x%" PRIX32 "\n" + " Used size: %zu bytes (0x%zX)", this->running_app_offset_, this->running_app_size_, this->running_app_size_); #ifdef USE_ESP32 @@ -378,7 +379,7 @@ void ESPHomeOTAComponent::handle_data_() { } ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %u bytes", ota_size); + ESP_LOGV(TAG, "Size is %zu bytes", ota_size); #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -397,7 +398,7 @@ void ESPHomeOTAComponent::handle_data_() { this->notify_state_(ota::OTA_STARTED, 0.0f, 0); #endif - // begin() may block for a few seconds while it locks flash. + // begin() returns quickly; flash sectors are erased incrementally during write(). error_code = this->backend_->begin(ota_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -587,8 +588,6 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) { } float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; } -uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; } -void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; } void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); @@ -749,7 +748,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() { this->auth_buf_[0] = this->auth_type_; hasher.get_hex(buf); - ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf); + ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf); } // Try to write auth_type + nonce @@ -809,13 +808,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() { hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer) hasher.calculate(); - ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce); + ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator) hasher.get_hex(computed_hash); - ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash); + ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash); #endif - ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response); + ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response); // Compare response bool matches = hasher.equals_hex(response); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0053ca6969..979e3f2d7d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -39,14 +39,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #endif // USE_OTA_PASSWORD /// Manually set the port OTA should listen on - void set_port(uint16_t port); + void set_port(uint16_t port) { this->port_ = port; } void setup() override; void dump_config() override; float get_setup_priority() const override; void loop() override; - uint16_t get_port() const; + uint16_t get_port() const { return this->port_; } protected: void handle_handshake_(); diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index 7861c0affa..ee3732c406 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi @@ -13,11 +15,12 @@ from esphome.const import ( CONF_TRIGGER_ID, CONF_WIFI, ) -from esphome.core import HexInt +from esphome.core import CORE, HexInt +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] - +AUTO_LOAD = ["network"] byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) @@ -41,7 +44,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 +59,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,10 +80,18 @@ 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: Any) -> 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): +def validate_channel(value: Any) -> int: if value is None: raise cv.Invalid("channel is required if wifi is not configured") return wifi.validate_channel(value) @@ -78,6 +103,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( @@ -104,7 +132,7 @@ CONFIG_SCHEMA = cv.All( ) -async def _trigger_to_code(config): +async def _trigger_to_code(config: ConfigType) -> MockObj: if address := config.get(CONF_ADDRESS): address = address.parts trigger = cg.new_Pvariable(config[CONF_TRIGGER_ID], address) @@ -113,18 +141,23 @@ async def _trigger_to_code(config): [ (ESPNowRecvInfoConstRef, "info"), (cg.uint8.operator("const").operator("ptr"), "data"), - (cg.uint8, "size"), + (cg.uint16, "size"), ], config, ) return trigger -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) 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 CONF_WIFI in CORE.config: + # Track the Wi-Fi channel via connect events instead of polling every loop + wifi.request_wifi_connect_state_listener() if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) @@ -150,13 +183,13 @@ async def to_code(config): # ========================================== A C T I O N S ================================================ -def validate_peer(value): +def validate_peer(value: Any) -> Any: if isinstance(value, cv.Lambda): return cv.returning_lambda(value) return cv.mac_address(value) -def _validate_raw_data(value): +def _validate_raw_data(value: Any) -> str | list: if isinstance(value, str): if len(value) > MAX_ESPNOW_PACKET_SIZE: raise cv.Invalid( @@ -174,7 +207,9 @@ def _validate_raw_data(value): ) -async def register_peer(var, config, args): +async def register_peer( + var: MockObj, config: ConfigType, args: TemplateArgsType +) -> None: peer = config[CONF_ADDRESS] if isinstance(peer, core.MACAddress): peer = [HexInt(p) for p in peer.parts] @@ -201,7 +236,7 @@ SEND_SCHEMA = PEER_SCHEMA.extend( ) -def _validate_send_action(config): +def _validate_send_action(config: ConfigType) -> ConfigType: if not config[CONF_WAIT_FOR_SENT] and not config[CONF_CONTINUE_ON_ERROR]: raise cv.Invalid( f"'{CONF_CONTINUE_ON_ERROR}' cannot be false if '{CONF_WAIT_FOR_SENT}' is false as the automation will not wait for the failed result.", @@ -237,7 +272,7 @@ async def send_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) @@ -286,7 +321,7 @@ async def peer_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) await register_peer(var, config, args) @@ -311,7 +346,7 @@ async def channel_action( action_id: core.ID, template_arg: cg.TemplateArguments, args: list[tuple], -): +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) 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 91d44394e8..ecf0f79e4a 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,9 +4,9 @@ #include "espnow_err.h" +#include #include -#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -28,9 +28,6 @@ namespace esphome::espnow { static constexpr const char *TAG = "espnow"; -static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50; -static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100; - ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const LogString *espnow_error_to_str(esp_err_t error) { @@ -77,6 +74,7 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) if (packet == nullptr) { // No events available - queue is full or we're out of memory global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -92,16 +90,27 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW send event - App.wake_loop_threadsafe(); + // Re-enable and wake the main loop to process the ESP-NOW send event + global_esp_now->enable_loop_soon_any_context(); } 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 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(); + global_esp_now->enable_loop_soon_any_context(); + return; + } + // Allocate an event from the pool ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory global_esp_now->receive_packet_queue_.increment_dropped_count(); + global_esp_now->enable_loop_soon_any_context(); return; } @@ -113,21 +122,24 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. - // Wake main loop immediately to process ESP-NOW receive event - App.wake_loop_threadsafe(); + // Re-enable and wake the main loop to process the ESP-NOW receive event + global_esp_now->enable_loop_soon_any_context(); } ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { - uint32_t version = 0; - esp_now_get_version(&version); - ESP_LOGCONFIG(TAG, "espnow:"); - if (this->is_disabled()) { - ESP_LOGCONFIG(TAG, " Disabled"); + // Only report driver details once enabled; with enable_on_boot: false the + // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // and after a failed enable_() the values would be meaningless. + if (this->state_ != ESPNOW_STATE_ENABLED) { + // OFF here means enable_() failed; the core logs the FAILED marker separately + ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } + uint32_t version = 0; + esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, @@ -149,12 +161,11 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { -#ifndef USE_WIFI - // Initialize LwIP stack for wake_loop_threadsafe() socket support - // When WiFi component is present, it handles esp_netif_init() - ESP_ERROR_CHECK(esp_netif_init()); +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->add_connect_state_listener(this); + } #endif - if (this->enable_on_boot_) { this->enable_(); } else { @@ -162,6 +173,19 @@ void ESPNowComponent::setup() { } } +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::span bssid) { + if (ssid.empty()) { + return; // Disconnected; the channel is only meaningful while associated + } + uint8_t old_channel = this->wifi_channel_; + this->get_wifi_channel(); + if (this->wifi_channel_ != old_channel) { + ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); + } +} +#endif + void ESPNowComponent::enable() { if (this->state_ == ESPNOW_STATE_ENABLED) return; @@ -174,8 +198,6 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - esp_event_loop_create_default(); - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_wifi_init(&cfg)); @@ -212,11 +234,6 @@ void ESPNowComponent::enable_() { esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); -#ifdef USE_DEEP_SLEEP - esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW); - esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL); -#endif - this->state_ = ESPNOW_STATE_ENABLED; for (auto peer : this->peers_) { @@ -260,15 +277,6 @@ void ESPNowComponent::apply_wifi_channel() { } void ESPNowComponent::loop() { -#ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { - int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); - if (new_channel != this->wifi_channel_) { - ESP_LOGI(TAG, "Wifi Channel is changed from %d to %" PRId32 ".", this->wifi_channel_, new_channel); - this->wifi_channel_ = new_channel; - } - } -#endif // Process received packets ESPNowPacket *packet = this->receive_packet_queue_.pop(); while (packet != nullptr) { @@ -292,11 +300,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_) { @@ -319,7 +330,9 @@ void ESPNowComponent::loop() { ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status))); #endif if (this->current_send_packet_ != nullptr) { - this->current_send_packet_->callback_(packet->packet_.sent.status); + if (this->current_send_packet_->callback_ != nullptr) { + this->current_send_packet_->callback_(packet->packet_.sent.status); + } this->send_packet_pool_.release(this->current_send_packet_); this->current_send_packet_ = nullptr; // Reset current packet after sending } @@ -341,13 +354,22 @@ void ESPNowComponent::loop() { // Log dropped received packets periodically uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count(); if (received_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped); + ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped); } // Log dropped send packets periodically uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count(); if (send_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped); + ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped); + } + + // Nothing left to do; sleep until a callback or send() re-enables the loop. + // A packet in flight (current_send_packet_) needs no loop time even when more + // packets are queued behind it: the send callback re-enables the loop when + // the result arrives, and the SENT event handler above starts the next send. + if (this->receive_packet_queue_.empty() && + (this->current_send_packet_ != nullptr || this->send_packet_queue_.empty())) { + this->disable_loop(); } } @@ -367,7 +389,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_) { @@ -391,6 +413,9 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl packet->load_data(peer_address, payload, size, callback); // Push the packet to the send queue this->send_packet_queue_.push(packet); + // Loop may be disabled while idle; re-enable it to send the packet + // (any-context variant so callers off the main loop are safe too) + this->enable_loop_soon_any_context(); return ESP_OK; } diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index ff9581ec2f..af693b47cf 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -2,6 +2,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/defines.h" #ifdef USE_ESP32 @@ -9,6 +10,10 @@ #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +#include "esphome/components/wifi/wifi_component.h" +#endif + #include #include @@ -62,7 +67,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 +79,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 +90,14 @@ 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 { +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) +class ESPNowComponent final : public Component, public wifi::WiFiConnectStateListener { +#else +class ESPNowComponent final : public Component { +#endif public: ESPNowComponent(); void setup() override; @@ -114,6 +123,11 @@ class ESPNowComponent : public Component { void set_auto_add_peer(bool value) { this->auto_add_peer_ = value; } +#if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) + // WiFiConnectStateListener interface: refresh the cached channel after each (re)connect + void on_wifi_connect_state(StringRef ssid, std::span bssid) override; +#endif + void enable(); void disable(); bool is_disabled() const { return this->state_ == ESPNOW_STATE_DISABLED; }; 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/__init__.py b/esphome/components/espnow/packet_transport/__init__.py index e6d66440db..ee4706ca1c 100644 --- a/esphome/components/espnow/packet_transport/__init__.py +++ b/esphome/components/espnow/packet_transport/__init__.py @@ -9,6 +9,7 @@ from esphome.components.packet_transport import ( import esphome.config_validation as cv from esphome.core import HexInt from esphome.cpp_types import PollingComponent +from esphome.types import ConfigType from .. import ESPNowComponent, espnow_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = transport_schema(ESPNowTransport).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: """Set up the ESP-NOW transport component.""" var, _ = await new_packet_transport(config) 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 3f88f8ef9a..0454440f14 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,9 +2,19 @@ from dataclasses import dataclass 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.config_helpers import filter_source_files_from_platform +from esphome.components import spi +from esphome.components.network import ( + add_use_address, + get_network_priority, + get_priority_interfaces_from_full_config, + ip_address_literal, +) +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -13,6 +23,7 @@ from esphome.const import ( CONF_DNS1, CONF_DNS2, CONF_DOMAIN, + CONF_ENABLE_ON_BOOT, CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, @@ -29,6 +40,7 @@ from esphome.const import ( CONF_POLLING_INTERVAL, CONF_RESET_PIN, CONF_SPI, + CONF_SPI_ID, CONF_STATIC_IP, CONF_SUBNET, CONF_TYPE, @@ -41,14 +53,15 @@ from esphome.const import ( ) from esphome.core import ( CORE, + ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType -CONFLICTS_WITH = ["wifi"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) @@ -124,6 +137,9 @@ 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, + "CH390": EthernetType.ETHERNET_TYPE_CH390, } # PHY types that need compile-time defines for conditional compilation @@ -143,6 +159,9 @@ _PHY_TYPE_TO_DEFINE = { "ENC28J60": "USE_ETHERNET_ENC28J60", "W6100": "USE_ETHERNET_W6100", "W6300": "USE_ETHERNET_W6300", + "GENERIC": "USE_ETHERNET_GENERIC", + "YT8531": "USE_ETHERNET_YT8531", + "CH390": "USE_ETHERNET_CH390", } @@ -163,19 +182,22 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), "W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"), - "DM9051": IDFRegistryComponent("espressif/dm9051", "1.0.0"), + "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), + "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), } # These types are always external IDF components (never built-in to ESP-IDF) -_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60"} +_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"} # 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 = { +SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"} +# 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", @@ -217,6 +239,10 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") +EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) +EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) +EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) +EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -239,10 +265,42 @@ def _is_framework_spi_polling_mode_supported() -> bool: return False +# Options that come from the referenced spi bus when spi_id is set +_SPI_BUS_PROVIDED_OPTIONS = ( + CONF_CLK_PIN, + CONF_MOSI_PIN, + CONF_MISO_PIN, + CONF_INTERFACE, +) + + +def _validate_spi_bus(config: ConfigType) -> ConfigType: + """Cross-validate spi_id against the options the referenced bus provides.""" + if CONF_SPI_ID in config: + for key in _SPI_BUS_PROVIDED_OPTIONS: + if key in config: + raise cv.Invalid( + f"'{key}' cannot be used together with '{CONF_SPI_ID}'; " + f"it comes from the referenced 'spi:' bus.", + path=[key], + ) + else: + for key in (CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN): + if key not in config: + raise cv.Invalid( + f"'{key}' is a required option when '{CONF_SPI_ID}' is not set.", + path=[key], + ) + return config + + def _validate_spi_interface(config: ConfigType) -> ConfigType: """Set default SPI interface or validate user choice against the variant.""" if not CORE.is_esp32: return config + if CONF_SPI_ID in config: + # The interface comes from the referenced spi bus; don't set a default. + return config from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant from esphome.components.spi import get_hw_interface_list @@ -257,7 +315,7 @@ def _validate_spi_interface(config: ConfigType) -> ConfigType: return config -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_USE_ADDRESS not in config: if CONF_MANUAL_IP in config: use_address = str(config[CONF_MANUAL_IP][CONF_STATIC_IP]) @@ -303,6 +361,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, @@ -318,7 +394,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.7.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, @@ -333,10 +409,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 @@ -348,6 +424,7 @@ BASE_SCHEMA = cv.Schema( cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), } @@ -385,38 +462,78 @@ RMII_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) -SPI_SCHEMA = cv.All( +# 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_CLK_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, - cv.Required(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, - cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_number, - cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, - cv.SplitDefault(CONF_CLOCK_SPEED, esp32="26.67MHz"): cv.All( - cv.only_on_esp32, - cv.frequency, - cv.int_range(int(8e6), int(80e6)), - ), - cv.Optional(CONF_INTERFACE): cv.All( - cv.only_on_esp32, - cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), - ), - # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() - cv.Optional(CONF_POLLING_INTERVAL): cv.All( - cv.only_on_esp32, - cv.positive_time_period_milliseconds, - cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), - ), + 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, Platform.RP2040]), - _validate_spi_interface, + cv.only_on([Platform.ESP32]), ) + +def _spi_schema(default_clock: str = "26.67MHz", max_clock: int = int(80e6)) -> cv.All: + return cv.All( + BASE_SCHEMA.extend( + cv.Schema( + { + # clk/mosi/miso are required unless spi_id is set; enforced + # by _validate_spi_bus below. + cv.Optional(CONF_CLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_MISO_PIN): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_MOSI_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_SPI_ID): cv.All( + cv.only_on_esp32, cv.use_id(spi.SPIComponent) + ), + cv.Required(CONF_CS_PIN): pins.internal_gpio_output_pin_number, + cv.Optional( + CONF_INTERRUPT_PIN + ): pins.internal_gpio_input_pin_number, + cv.Optional(CONF_RESET_PIN): pins.internal_gpio_output_pin_number, + cv.SplitDefault(CONF_CLOCK_SPEED, esp32=default_clock): cv.All( + cv.only_on_esp32, + cv.frequency, + cv.int_range(int(8e6), max_clock), + ), + cv.Optional(CONF_INTERFACE): cv.All( + cv.only_on_esp32, + cv.one_of(*SPI_INTERFACE_MAP.keys(), lower=True), + ), + # Set default value (SPI_ETHERNET_DEFAULT_POLLING_INTERVAL) at _validate() + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.only_on_esp32, + cv.positive_time_period_milliseconds, + cv.Range(min=TimePeriodMilliseconds(milliseconds=1)), + ), + } + ), + ), + cv.only_on([Platform.ESP32, Platform.RP2]), + _validate_spi_bus, + _validate_spi_interface, + ) + + +SPI_SCHEMA = _spi_schema() + +# The ENC28J60's SCK maximum is 20 MHz, so the shared 26.67 MHz default is out +# of spec for it and makes the driver's CS hold time helper compute no hold +SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6)) + +# The CH390H/D rates SCK at 50 MHz typical and 72 MHz maximum with VDDIO at 3.3V, +# so the shared 80 MHz ceiling is out of spec while the 26.67 MHz default is not. +# CH390 datasheet v1.8, tables 9-4 and 9-5: +# https://www.wch-ic.com/downloads/CH390DS1_PDF.html +SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6)) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { @@ -427,14 +544,17 @@ 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])), + "CH390": SPI_SCHEMA_CH390, + "ENC28J60": SPI_SCHEMA_ENC28J60, + "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, ), @@ -442,13 +562,37 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate_spi(config): +def _final_validate_spi(config: ConfigType) -> None: if not CORE.is_esp32: return # SPI interface validation is ESP32-only if config[CONF_TYPE] not in SPI_ETHERNET_TYPES: return from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface + if CONF_SPI_ID in config: + # Sharing the bus: the standard spi device schema enforces that the + # referenced bus declares both data lines. The IDF ethernet drivers + # additionally need a hardware host, which shows as an interface index + # on the validated bus config. + spi.final_validate_device_schema( + "ethernet", require_mosi=True, require_miso=True + )(config) + cv.Schema( + { + cv.Required(CONF_SPI_ID): fv.id_declaration_match_schema( + { + cv.Required( + CONF_INTERFACE_INDEX, + msg="Component ethernet requires this spi bus to use " + "a hardware interface", + ): cv.valid + } + ) + }, + extra=cv.ALLOW_EXTRA, + )(config) + return + if spi_configs := fv.full_config.get().get(CONF_SPI): # get_spi_interface() returns strings like "SPI2_HOST" spi_host = f"{config[CONF_INTERFACE].upper()}_HOST" @@ -462,7 +606,7 @@ def _final_validate_spi(config): ) -def manual_ip(config): +def manual_ip(config: ConfigType) -> cg.StructInitializer: return cg.StructInitializer( ManualIP, ("static_ip", ip_address_literal(config[CONF_STATIC_IP])), @@ -473,7 +617,7 @@ def manual_ip(config): ) -def phy_register(address: int, value: int, page: int): +def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: return cg.StructInitializer( PHYRegister, ("address", address), @@ -483,17 +627,28 @@ def phy_register(address: int, value: int, page: int): @coroutine_with_priority(CoroPriority.COMMUNICATION) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) + + # Apply network priority before register_component (which emits the user's + # explicit setup_priority: if set) so that, as in wifi, an explicit + # setup_priority: still wins over the network-priority-derived value. + prio = get_network_priority("ethernet") + if prio is not None: + cg.set_setup_priority(var, prio) + await cg.register_component(var, 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)) CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: @@ -524,18 +679,25 @@ async def to_code(config): CORE.add_job(final_step) -async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_ethernet, ) if config[CONF_TYPE] in SPI_ETHERNET_TYPES: - cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) - cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) - cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) + if (spi_id := config.get(CONF_SPI_ID)) is not None: + # Pins and host come from the shared spi bus. + spi_parent = await cg.get_variable(spi_id) + cg.add(var.set_spi_parent(spi_parent)) + else: + cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) + cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) + cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) + cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) if CONF_INTERRUPT_PIN in config: cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) @@ -549,17 +711,33 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: cg.add_define("USE_ETHERNET_SPI") - cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) add_idf_sdkconfig_option("CONFIG_ETH_USE_SPI_ETHERNET", True) # CONFIG_ETH_SPI_ETHERNET_{TYPE} Kconfig options were removed in IDF 6.0 - # ENC28J60 was never built-in to IDF, so it has no Kconfig option - if idf_version() < cv.Version(6, 0, 0) and config[CONF_TYPE] != "ENC28J60": + # Types that are never built into IDF ship no Kconfig option at all + if ( + idf_version() < cv.Version(6, 0, 0) + and config[CONF_TYPE] not in _ALWAYS_EXTERNAL_IDF_COMPONENTS + ): add_idf_sdkconfig_option( f"CONFIG_ETH_SPI_ETHERNET_{config[CONF_TYPE]}", True ) 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])) @@ -576,10 +754,10 @@ 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. It disables the + # WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi, + # so multi-interface configs (network: priority: with both) keep WiFi. + request_ethernet() # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") @@ -594,7 +772,7 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: add_idf_component(name=component.name, ref=component.version) -async def _to_code_rp2040(var: cg.Pvariable, config: ConfigType) -> None: +async def _to_code_rp2040(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) @@ -605,7 +783,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: @@ -663,18 +841,33 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: raise cv.Invalid(error_msg, path=pin_path) -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Final validation for Ethernet component.""" + # Allow ethernet + wifi coexistence only when both are declared in network: priority:. + if "wifi" in fv.full_config.get(): + priority_ifaces = get_priority_interfaces_from_full_config(fv.full_config.get()) + missing = [i for i in ("ethernet", "wifi") if i not in priority_ifaces] + if missing and priority_ifaces: + # A priority list exists but is incomplete: point at what to add. + raise cv.Invalid( + "When ethernet and wifi are used together, 'network: priority:' must " + f"list both interfaces; missing: {', '.join(missing)}" + ) + if missing: + raise cv.Invalid( + "Component ethernet cannot be used together with component wifi " + "unless both are listed under 'network: priority:'" + ) + _final_validate_spi(config) _final_validate_rmii_pins(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional Ethernet features.""" if ip_state_count := CORE.data.get(ETHERNET_IP_STATE_LISTENERS_KEY, 0): cg.add_define("USE_ETHERNET_IP_STATE_LISTENERS") @@ -687,17 +880,28 @@ _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, }, + "w5500_custom_spi.cpp": { + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + }, } ) +# The custom W5500 SPI driver is fully #ifdef'd on USE_ESP32 and +# USE_ETHERNET_W5500 (the platform filter map above handles non-ESP32). +_define_filter = filter_source_files_from_defines( + {"w5500_custom_spi.cpp": "USE_ETHERNET_W5500"} +) + + def _filter_source_files() -> list[str]: - excluded = _platform_filter() + excluded = _platform_filter() + _define_filter() eth_data = CORE.data.get(KEY_ETHERNET, {}) eth_type = eth_data.get(ETHERNET_TYPE_KEY) # Only compile the custom JL1101 driver when JL1101 is configured @@ -711,7 +915,31 @@ def _filter_source_files() -> list[str]: # to avoid shadowing. Native IDF builds always need the custom driver. if cv.Version(5, 4, 2) <= idf_version() < cv.Version(6, 0, 0): excluded.append("esp_eth_phy_jl1101.c") - return excluded + # The platform and define filters can both name the same file + return list(dict.fromkeys(excluded)) FILTER_SOURCE_FILES = _filter_source_files + + +async def _new_pvariable_to_code( + config: ConfigType, + id_: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return cg.new_Pvariable(id_, template_arg) + + +for _name, _cls in ( + ("ethernet.connected", EthernetConnectedCondition), + ("ethernet.enabled", EthernetEnabledCondition), +): + automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) +for _name, _cls in ( + ("ethernet.enable", EthernetEnableAction), + ("ethernet.disable", EthernetDisableAction), +): + automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( + _new_pvariable_to_code + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h new file mode 100644 index 0000000000..f975f52bff --- /dev/null +++ b/esphome/components/ethernet/automation.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ETHERNET +#include "ethernet_component.h" + +namespace esphome::ethernet { + +template class EthernetConnectedCondition final : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_connected(); } +}; + +template class EthernetEnabledCondition final : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } +}; + +template class EthernetEnableAction final : public Action { + public: + void play(const Ts &...x) override { global_eth_component->enable(); } +}; + +template class EthernetDisableAction final : public Action { + public: + void play(const Ts &...x) override { global_eth_component->disable(); } +}; + +} // namespace esphome::ethernet +#endif diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 42cb0b3cfc..14a4fd660b 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -10,14 +10,6 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non- EthernetComponent::EthernetComponent() { global_eth_component = this; } -float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } - -void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } - -#ifdef USE_ETHERNET_MANUAL_IP -void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } -#endif - #ifdef USE_ETHERNET_IP_STATE_LISTENERS void EthernetComponent::notify_ip_state_listeners_() { auto ips = this->get_ip_addresses(); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 17c84ee954..2b67b9093b 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -13,6 +13,9 @@ #include "esp_eth.h" #ifdef USE_ETHERNET_SPI #include "hal/spi_types.h" +#ifdef USE_SPI +#include "esphome/components/spi/spi.h" +#endif #endif #include "esp_eth_mac.h" #include "esp_eth_mac_esp.h" @@ -25,7 +28,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 +89,9 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_ENC28J60, ETHERNET_TYPE_W6100, ETHERNET_TYPE_W6300, + ETHERNET_TYPE_GENERIC, + ETHERNET_TYPE_YT8531, + ETHERNET_TYPE_CH390, }; struct ManualIP { @@ -110,8 +116,10 @@ enum class EthernetComponentState : uint8_t { // Platform-neutral duplex/speed types #ifndef USE_ESP32 +// NOLINTBEGIN(readability-identifier-naming) enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL }; enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M }; +// NOLINTEND(readability-identifier-naming) #endif class EthernetComponent final : public Component { @@ -120,24 +128,40 @@ class EthernetComponent final : public Component { void setup() override; void loop() override; void dump_config() override; - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::ETHERNET; } void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } - void set_type(EthernetType type); -#ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); + // Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled). + // enable_on_boot defaults to true; when false, setup() runs all the driver/netif + // installation but skips esp_eth_start(), keeping the link cold until enable() is + // called. This is the primary lever for memory reclamation in multi-interface + // configurations where only one interface should carry traffic at a time. + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void enable(); + void disable(); + bool is_disabled() { return this->disabled_; } + bool is_enabled() { return !this->disabled_; } + +#ifdef USE_ESP32 + /// esp_netif handle, used by network for default-route arbitration. + /// nullptr until the driver/netif installation has run. + esp_netif_t *get_esp_netif() { return this->eth_netif_; } #endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + + void set_type(EthernetType type) { this->type_ = type; } +#ifdef USE_ETHERNET_MANUAL_IP + void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } +#endif + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } 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); - // Remove before 2026.9.0 - ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0") - std::string get_eth_mac_address_pretty(); const char *get_eth_mac_address_pretty_into_buffer(std::span buf); eth_duplex_t get_duplex_mode(); eth_speed_t get_link_speed(); @@ -147,36 +171,39 @@ class EthernetComponent final : public Component { esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; } #ifdef USE_ETHERNET_SPI - 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(uint8_t interrupt_pin); - void set_reset_pin(uint8_t reset_pin); - void set_clock_speed(int clock_speed); - void set_interface(spi_host_device_t interface); + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } + void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } + void set_interface(spi_host_device_t interface) { this->interface_ = interface; } +#ifdef USE_SPI + void set_spi_parent(spi::SPIComponent *parent) { this->spi_parent_ = parent; } +#endif #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT - void set_polling_interval(uint32_t polling_interval); + void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } #endif #else - void set_phy_addr(uint8_t phy_addr); - void set_power_pin(int power_pin); - void set_mdc_pin(uint8_t mdc_pin); - void set_mdio_pin(uint8_t mdio_pin); - void set_clk_pin(uint8_t clk_pin); - void set_clk_mode(emac_rmii_clock_mode_t clk_mode); + void set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } + void set_power_pin(int power_pin) { this->power_pin_ = power_pin; } + void set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } + void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } void add_phy_register(PHYRegister register_value); #endif // USE_ETHERNET_SPI #endif // USE_ESP32 -#ifdef USE_RP2040 - 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 +#ifdef USE_RP2 + void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } + void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } + void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } + void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } + void set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } + void set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } +#endif // USE_RP2 #ifdef USE_ETHERNET_IP_STATE_LISTENERS void add_ip_state_listener(EthernetIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } @@ -194,6 +221,16 @@ class EthernetComponent final : public Component { void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver + // install, netif attach, and event handler registration (which together allocate + // ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue) + // until ethernet actually needs to come up. Idempotent — guarded by the + // ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or + // from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi. + void ethernet_lazy_init_(); +#endif + #ifdef USE_ETHERNET_IP_STATE_LISTENERS void notify_ip_state_listeners_(); #endif @@ -208,6 +245,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); @@ -222,6 +264,11 @@ class EthernetComponent final : public Component { int phy_addr_spi_{-1}; int clock_speed_; spi_host_device_t interface_{SPI2_HOST}; +#ifdef USE_SPI + // When set, the SPI bus is owned and initialized by this spi component + // and the ethernet chip only adds a device to it. + spi::SPIComponent *spi_parent_{nullptr}; +#endif #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT uint32_t polling_interval_{0}; #endif @@ -244,7 +291,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 @@ -273,7 +320,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 @@ -287,12 +334,23 @@ class EthernetComponent final : public Component { bool started_{false}; bool connected_{false}; bool got_ipv4_address_{false}; + // Codegen-time YAML option. When false, setup() defers esp_eth_start(). + bool enable_on_boot_{true}; + // Mirror of "is the link intentionally stopped" — set when setup() honors + // enable_on_boot=false, cleared by enable(), set again by disable(). + bool disabled_{false}; +#ifdef USE_ESP32 + // Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable() + // to be called at runtime after enable_on_boot:false without re-allocating, and + // ensures setup() skips the heavy init when enable_on_boot_ is false. + bool ethernet_initialized_{false}; +#endif #if LWIP_IPV6 uint8_t ipv6_count_{0}; bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - optional> fixed_mac_; + optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS StaticVector ip_state_listeners_; @@ -307,7 +365,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 d4585bf100..1d9903271e 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "w5500_custom_spi.h" #include #include @@ -49,9 +50,18 @@ #include "esp_eth_enc28j60.h" #endif +// CH390 headers exist on all IDF versions (always an external component) +#ifdef USE_ETHERNET_CH390 +#include "esp_eth_mac_ch390.h" +#include "esp_eth_phy_ch390.h" +#endif + #ifdef USE_ETHERNET_SPI #include #include +#ifdef USE_SPI +#include "esphome/components/spi/spi.h" +#endif #endif namespace esphome::ethernet { @@ -137,37 +147,60 @@ void EthernetComponent::setup() { delay(300); // NOLINT } + if (this->enable_on_boot_) { + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + // lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do. + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); + } else { + ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)"); + this->disabled_ = true; + } +} + +void EthernetComponent::ethernet_lazy_init_() { + if (this->ethernet_initialized_) + return; + esp_err_t err; #ifdef USE_ETHERNET_SPI // Install GPIO ISR handler to be able to service SPI Eth modules interrupts gpio_install_isr_service(0); - spi_bus_config_t buscfg = { - .mosi_io_num = this->mosi_pin_, - .miso_io_num = this->miso_pin_, - .sclk_io_num = this->clk_pin_, - .quadwp_io_num = -1, - .quadhd_io_num = -1, - .data4_io_num = -1, - .data5_io_num = -1, - .data6_io_num = -1, - .data7_io_num = -1, - .max_transfer_sz = 0, - .flags = 0, - .intr_flags = 0, - }; - - auto host = this->interface_; - - err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); - ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); + spi_host_device_t host; +#ifdef USE_SPI + if (this->spi_parent_ != nullptr) { + // The bus is owned and already initialized by the spi component; share its host. + host = this->spi_parent_->get_interface(); + } else #endif + { + spi_bus_config_t buscfg = { + .mosi_io_num = this->mosi_pin_, + .miso_io_num = this->miso_pin_, + .sclk_io_num = this->clk_pin_, + .quadwp_io_num = -1, + .quadhd_io_num = -1, + .data4_io_num = -1, + .data5_io_num = -1, + .data6_io_num = -1, + .data7_io_num = -1, + .max_transfer_sz = 0, + .flags = 0, + .intr_flags = 0, + }; - err = esp_netif_init(); - ESPHL_ERROR_CHECK(err, "ETH netif init error"); - err = esp_event_loop_create_default(); - ESPHL_ERROR_CHECK(err, "ETH event loop error"); + host = this->interface_; + + err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); + ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); + } +#endif + // Network interface setup handled by network component esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); this->eth_netif_ = esp_netif_new(&cfg); @@ -200,6 +233,8 @@ void EthernetComponent::setup() { eth_dm9051_config_t dm9051_config = ETH_DM9051_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_ENC28J60) eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_CH390) + eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -207,14 +242,25 @@ void EthernetComponent::setup() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; #endif + // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait + // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which + // runs the driver's init(). + install_w5500_async_spi(w5500_config); #elif defined(USE_ETHERNET_DM9051) dm9051_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT dm9051_config.poll_period_ms = this->polling_interval_; #endif #elif defined(USE_ETHERNET_ENC28J60) + // ENC28J60 does not support poll_period_ms. CS must stay asserted for the chip's CS hold + // time (t10, 210 ns) after the last clock or MAC/MII register reads fail ("wrong chip ID") + enc28j60_config.spi_devcfg->cs_ena_posttrans = enc28j60_cal_spi_cs_hold_time((this->clock_speed_ + 999999) / 1000000); enc28j60_config.int_gpio_num = this->interrupt_pin_; - // ENC28J60 does not support poll_period_ms +#elif defined(USE_ETHERNET_CH390) + ch390_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ch390_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -235,9 +281,14 @@ void EthernetComponent::setup() { 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 @@ -300,6 +351,20 @@ void EthernetComponent::setup() { 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) @@ -320,6 +385,12 @@ void EthernetComponent::setup() { this->phy_ = esp_eth_phy_new_enc28j60(&phy_config); break; } +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: { + mac = esp_eth_mac_new_ch390(&ch390_config, &mac_config); + this->phy_ = esp_eth_phy_new_ch390(&phy_config); + break; + } #endif #endif default: { @@ -344,12 +415,35 @@ void EthernetComponent::setup() { 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]; + uint8_t mac_addr[MAC_ADDRESS_SIZE]; if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); + memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac_addr, ESP_MAC_ETH); } @@ -370,9 +464,41 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); #endif /* USE_NETWORK_IPV6 */ - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); + this->ethernet_initialized_ = true; +} + +void EthernetComponent::enable() { + if (!this->disabled_) + return; + + ESP_LOGD(TAG, "Enabling"); + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + ESP_LOGE(TAG, "Cannot enable - init failed"); + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err)); + return; + } + this->disabled_ = false; + // The ETH_EVENT_START handler will set started_=true; the loop state machine + // will then drive the STOPPED -> CONNECTING -> CONNECTED transitions. + this->enable_loop(); +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + + ESP_LOGD(TAG, "Disabling"); + esp_err_t err = esp_eth_stop(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err)); + } + this->disabled_ = true; + // ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED. } void EthernetComponent::dump_config() { @@ -424,6 +550,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_ENC28J60: eth_type = "ENC28J60"; break; +#elif defined(USE_ETHERNET_CH390) + case ETHERNET_TYPE_CH390: + eth_type = "CH390"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: @@ -435,6 +565,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"; @@ -447,17 +587,25 @@ void EthernetComponent::dump_config() { YESNO(this->is_connected())); this->dump_connect_params_(); #ifdef USE_ETHERNET_SPI - ESP_LOGCONFIG(TAG, - " CLK Pin: %u\n" - " MISO Pin: %u\n" - " MOSI Pin: %u\n" - " CS Pin: %u", - this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); - const char *spi_interface = "spi3"; - if (this->interface_ == SPI2_HOST) { - spi_interface = "spi2"; +#ifdef USE_SPI + if (this->spi_parent_ != nullptr) { + // Pins and interface come from the shared spi bus; only CS is ours. + ESP_LOGCONFIG(TAG, " CS Pin: %u", this->cs_pin_); + } else +#endif + { + ESP_LOGCONFIG(TAG, + " CLK Pin: %u\n" + " MISO Pin: %u\n" + " MOSI Pin: %u\n" + " CS Pin: %u", + this->clk_pin_, this->miso_pin_, this->mosi_pin_, this->cs_pin_); + const char *spi_interface = "spi3"; + if (this->interface_ == SPI2_HOST) { + spi_interface = "spi2"; + } + ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface); } - ESP_LOGCONFIG(TAG, " Interface: %s", spi_interface); #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT if (this->polling_interval_ != 0) { ESP_LOGCONFIG(TAG, " Polling Interval: %" PRIu32 " ms", this->polling_interval_); @@ -486,6 +634,8 @@ void EthernetComponent::dump_config() { network::IPAddresses EthernetComponent::get_ip_addresses() { network::IPAddresses addresses; + if (!this->ethernet_initialized_) + return addresses; // all-zero IPs esp_netif_ip_info_t ip; esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); if (err != ESP_OK) { @@ -659,16 +809,25 @@ void EthernetComponent::start_connect_() { #ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { - LwIPLock lock; + // Set DNS through esp_netif so the servers are stored in the netif's own + // dns[] array; raw dns_setserver() would be lost when the default-route + // arbitration re-applies the default netif's DNS. + // Log-only on failure: the link still has a working IP/gateway, so degraded + // name resolution does not justify marking the whole component failed. + esp_netif_dns_info_t dns{}; if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); + dns.ip = this->manual_ip_->dns1; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err)); + } } if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); + dns.ip = this->manual_ip_->dns2; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err)); + } } } else #endif @@ -708,6 +867,10 @@ void EthernetComponent::start_connect_() { } void EthernetComponent::dump_connect_params_() { + if (!this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " uninitialized/disabled"); + return; + } esp_netif_ip_info_t ip; esp_netif_get_ip_info(this->eth_netif_, &ip); const ip_addr_t *dns_ip1; @@ -725,6 +888,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" @@ -739,7 +915,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]; @@ -752,48 +928,37 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifdef USE_ETHERNET_SPI -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; } -void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; } -void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; } -#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT -void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; } -#endif -#else -void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; } -void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; } -void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; } -void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } +#ifndef USE_ETHERNET_SPI void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (!this->ethernet_initialized_) { + // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless + // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. + if (this->fixed_mac_.has_value()) { + memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); + } else { + esp_read_mac(mac, ESP_MAC_ETH); + } + return; + } esp_err_t err; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); } eth_duplex_t EthernetComponent::get_duplex_mode() { + if (!this->ethernet_initialized_) + return ETH_DUPLEX_HALF; esp_err_t err; eth_duplex_t duplex_mode; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); @@ -802,6 +967,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() { } eth_speed_t EthernetComponent::get_link_speed() { + if (!this->ethernet_initialized_) + return ETH_SPEED_10M; esp_err_t err; eth_speed_t speed; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); @@ -887,6 +1054,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 91% rename from esphome/components/ethernet/ethernet_component_rp2040.cpp rename to esphome/components/ethernet/ethernet_component_rp2.cpp index ef7bd46332..94d84cc891 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(); @@ -187,17 +187,18 @@ void EthernetComponent::loop() { } void EthernetComponent::dump_config() { - const char *type_str = "Unknown"; #if defined(USE_ETHERNET_W5500) - type_str = "W5500"; + const char *type_str = "W5500"; #elif defined(USE_ETHERNET_W5100) - type_str = "W5100"; + const char *type_str = "W5100"; #elif defined(USE_ETHERNET_W6100) - type_str = "W6100"; + const char *type_str = "W6100"; #elif defined(USE_ETHERNET_W6300) - type_str = "W6300"; + const char *type_str = "W6300"; #elif defined(USE_ETHERNET_ENC28J60) - type_str = "ENC28J60"; + const char *type_str = "ENC28J60"; +#else + const char *type_str = "Unknown"; #endif #if defined(USE_ETHERNET_W6300) // W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used @@ -244,18 +245,13 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (this->eth_ != nullptr) { this->eth_->macAddress(mac); } else { - memset(mac, 0, 6); + memset(mac, 0, MAC_ADDRESS_SIZE); } } -std::string EthernetComponent::get_eth_mac_address_pretty() { - char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - return std::string(this->get_eth_mac_address_pretty_into_buffer(buf)); -} - const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); @@ -354,13 +350,23 @@ void EthernetComponent::dump_connect_params_() { this->get_eth_mac_address_pretty_into_buffer(mac_buf)); } -void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } -void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; } -void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; } -void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } -void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } -void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } +void EthernetComponent::enable() { + // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally; + // there is no clean enable/disable hook today. The YAML option is accepted on + // RP2040 for schema parity but has no effect. + if (!this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = false; +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = true; +} } // namespace esphome::ethernet -#endif // USE_ETHERNET && USE_RP2040 +#endif // USE_ETHERNET && USE_RP2 diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp new file mode 100644 index 0000000000..ed4f149738 --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -0,0 +1,118 @@ +#include "w5500_custom_spi.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +#include +#include +#include +#include + +namespace esphome::ethernet { + +namespace { + +// Per-device context returned by init() and handed back to read/write/deinit. +struct W5500CustomSpiContext { + spi_device_handle_t handle; + SemaphoreHandle_t lock; +}; + +// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger +// transfers (the frame payloads) use the blocking, DMA-backed transmit. +constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64; +constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50; + +void *w5500_custom_spi_init(const void *spi_config) { + const auto *config = static_cast(spi_config); + auto *ctx = new (std::nothrow) W5500CustomSpiContext{}; + if (ctx == nullptr) { + return nullptr; + } + // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control + // byte in the address phase; mirror what the stock driver configures. + spi_device_interface_config_t devcfg = *config->spi_devcfg; + devcfg.command_bits = 16; + devcfg.address_bits = 8; + if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { + delete ctx; + return nullptr; + } + ctx->lock = xSemaphoreCreateMutex(); + if (ctx->lock == nullptr) { + spi_bus_remove_device(ctx->handle); + delete ctx; + return nullptr; + } + return ctx; +} + +esp_err_t w5500_custom_spi_deinit(void *spi_ctx) { + auto *ctx = static_cast(spi_ctx); + spi_bus_remove_device(ctx->handle); + vSemaphoreDelete(ctx->lock); + delete ctx; + return ESP_OK; +} + +// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size. +// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register +// accesses stay on the cheaper polling path. Used by both read and write. +esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) { + if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + esp_err_t ret; + if (len > W5500_SPI_BULK_THRESHOLD) { + ret = spi_device_transmit(ctx->handle, trans); + } else { + ret = spi_device_polling_transmit(ctx->handle, trans); + } + xSemaphoreGive(ctx->lock); + return ret; +} + +esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.tx_buffer = data; + return w5500_custom_spi_transfer(ctx, &trans, len); +} + +esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) { + auto *ctx = static_cast(spi_ctx); + spi_transaction_t trans = {}; + // Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary + // overwrites of adjacent registers (same guard the stock driver uses). + const bool use_rxdata = len <= 4; + trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0; + trans.cmd = static_cast(cmd); + trans.addr = addr; + trans.length = 8 * len; + trans.rx_buffer = data; + esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len); + if (use_rxdata && (ret == ESP_OK)) { + memcpy(data, trans.rx_data, len); + } + return ret; +} + +} // namespace + +void install_w5500_async_spi(eth_w5500_config_t &config) { + // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and + // spi_devcfg back out of it. The self-reference is valid because both the config and the + // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init(). + config.custom_spi_driver.config = &config; + config.custom_spi_driver.init = w5500_custom_spi_init; + config.custom_spi_driver.deinit = w5500_custom_spi_deinit; + config.custom_spi_driver.read = w5500_custom_spi_read; + config.custom_spi_driver.write = w5500_custom_spi_write; +} + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 diff --git a/esphome/components/ethernet/w5500_custom_spi.h b/esphome/components/ethernet/w5500_custom_spi.h new file mode 100644 index 0000000000..8756a149af --- /dev/null +++ b/esphome/components/ethernet/w5500_custom_spi.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500) + +#include +// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t +// is no longer reachable through esp_eth.h and needs the explicit header. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +#include +#else +#include +#endif + +namespace esphome::ethernet { + +// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path. +// +// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which +// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame, +// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX +// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers +// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps +// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait +// is cheaper than an interrupt round-trip. +// +// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back +// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay +// alive until esp_eth_mac_new_w5500() returns. +void install_w5500_async_spi(eth_w5500_config_t &config); + +} // namespace esphome::ethernet + +#endif // USE_ESP32 && USE_ETHERNET_W5500 diff --git a/esphome/components/ethernet_info/text_sensor.py b/esphome/components/ethernet_info/text_sensor.py index 8c20cf332c..66483cdb85 100644 --- a/esphome/components/ethernet_info/text_sensor.py +++ b/esphome/components/ethernet_info/text_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_MAC_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC, ) +from esphome.types import ConfigType DEPENDENCIES = ["ethernet"] @@ -46,7 +47,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: # Request Ethernet IP state listener slots - one per sensor type if CONF_IP_ADDRESS in config: ethernet.request_ethernet_ip_state_listener() diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 4cab1bff9b..881107b713 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -16,14 +16,15 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_MOTION, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@nohat"] IS_PLATFORM_COMPONENT = True @@ -50,7 +51,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({}), } ) @@ -91,7 +94,9 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("event") -async def setup_event_core_(var, config, *, event_types: list[str]): +async def setup_event_core_( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_event_types(event_types)) @@ -106,7 +111,9 @@ async def setup_event_core_(var, config, *, event_types: list[str]): await web_server.add_entity_config(var, web_server_config) -async def register_event(var, config, *, event_types: list[str]): +async def register_event( + var: MockObj, config: ConfigType, *, event_types: list[str] +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("event", config) @@ -114,7 +121,7 @@ async def register_event(var, config, *, event_types: list[str]): await setup_event_core_(var, config, event_types=event_types) -async def new_event(config, *, event_types: list[str]): +async def new_event(config: ConfigType, *, event_types: list[str]) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await register_event(var, config, event_types=event_types) return var @@ -131,7 +138,12 @@ TRIGGER_EVENT_SCHEMA = cv.Schema( @automation.register_action( "event.trigger", TriggerEventAction, TRIGGER_EVENT_SCHEMA, synchronous=True ) -async def event_fire_to_code(config, action_id, template_arg, args): +async def event_fire_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) templ = await cg.templatable(config[CONF_EVENT_TYPE], args, cg.std_string) @@ -140,5 +152,5 @@ async def event_fire_to_code(config, action_id, template_arg, args): @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(event_ns.using) 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/__init__.py b/esphome/components/exposure_notifications/__init__.py index ab7416a264..4f7e698e23 100644 --- a/esphome/components/exposure_notifications/__init__.py +++ b/esphome/components/exposure_notifications/__init__.py @@ -1,39 +1,65 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg -from esphome.components import esp32_ble_tracker +from esphome.components import ble_device_base import esphome.config_validation as cv from esphome.const import CONF_TRIGGER_ID +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications") ExposureNotification = exposure_notifications_ns.struct("ExposureNotification") ExposureNotificationTrigger = exposure_notifications_ns.class_( "ExposureNotificationTrigger", - esp32_ble_tracker.ESPBTDeviceListener, + ble_device_base.ESPBTDeviceListener, automation.Trigger.template(ExposureNotification), ) CONF_ON_EXPOSURE_NOTIFICATION = "on_exposure_notification" +_RENAME_HUB_ID = ble_device_base.rename_legacy_hub_id("exposure_notifications") + +_VALIDATE_AUTOMATION = automation.validate_automation( + cv.Schema( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ExposureNotificationTrigger), + } + # The trigger is the BLE listener, so the hub id lives on it. + ).extend(ble_device_base.BLE_DEVICE_SCHEMA) +) + + +# validate_automation() needs a dict-based schema, so the rename cannot go +# inside it and has to run on the option value first. That value may also be a +# list of automations or malformed, and rename_legacy_hub_id() is dict-only, so +# map over lists and let validate_automation() report anything else. +# schema_extractor keeps the key typed as a trigger in the generated editor +# schema; build_language_schema.py recurses into cv.All but not into a plain +# function. +@schema_extractor("automation") +def _validate_on_exposure_notification(value: Any) -> list[ConfigType]: + if value is SCHEMA_EXTRACT: + return _VALIDATE_AUTOMATION(value) + if isinstance(value, dict): + value = _RENAME_HUB_ID(value) + elif isinstance(value, list): + value = [_RENAME_HUB_ID(v) if isinstance(v, dict) else v for v in value] + return _VALIDATE_AUTOMATION(value) + + CONFIG_SCHEMA = cv.Schema( { - cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): automation.validate_automation( - cv.Schema( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ExposureNotificationTrigger - ), - } - ).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) - ), + cv.Required(CONF_ON_EXPOSURE_NOTIFICATION): _validate_on_exposure_notification, } ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: for conf in config.get(CONF_ON_EXPOSURE_NOTIFICATION, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) await automation.build_automation(trigger, [(ExposureNotification, "x")], conf) - await esp32_ble_tracker.register_ble_device(trigger, conf) + await ble_device_base.register_ble_device(trigger, conf) diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index e7038d2ca9..4f4b93b59c 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -2,11 +2,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { -using namespace esp32_ble_tracker; +using namespace ble_device_base; static const char *const TAG = "exposure_notifications"; @@ -43,5 +41,3 @@ bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { } } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/exposure_notifications/exposure_notifications.h b/esphome/components/exposure_notifications/exposure_notifications.h index 80184f9cfd..dc1241db56 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.h +++ b/esphome/components/exposure_notifications/exposure_notifications.h @@ -2,11 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/automation.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/ble_device_base/ble_device.h" #include -#ifdef USE_ESP32 - namespace esphome::exposure_notifications { struct ExposureNotification { @@ -16,12 +14,10 @@ struct ExposureNotification { std::array associated_encrypted_metadata; }; -class ExposureNotificationTrigger : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { +class ExposureNotificationTrigger final : public Trigger, + public ble_device_base::ESPBTDeviceListener { public: - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; }; } // namespace esphome::exposure_notifications - -#endif diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 6eb577e5ad..504b1ae679 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -71,6 +71,33 @@ def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> P return components_dir +def _log_overridden_components( + conf: dict[str, Any], component_names: list[str] +) -> None: + overridden = [ + name + for name in component_names + if (loader.CORE_COMPONENTS_PATH / name / "__init__.py").is_file() + ] + if not overridden: + return + if conf[CONF_TYPE] == TYPE_GIT: + source = conf[CONF_URL] + if ref := conf.get(CONF_REF): + source = f"{source}@{ref}" + if path := conf.get(CONF_PATH): + source = f"{source} ({path})" + else: + source = conf[CONF_PATH] + _LOGGER.info( + "External components are overriding built-in components:\n" + " source: %s\n" + " components: %s", + source, + ", ".join(sorted(overridden)), + ) + + def _process_single_config(config: dict[str, Any]) -> None: conf = config[CONF_SOURCE] if conf[CONF_TYPE] == TYPE_GIT: @@ -81,11 +108,11 @@ def _process_single_config(config: dict[str, Any]) -> None: elif conf[CONF_TYPE] == TYPE_LOCAL: components_dir = Path(CORE.relative_config_path(conf[CONF_PATH])) else: - raise NotImplementedError() + raise NotImplementedError if config[CONF_COMPONENTS] == "all": - num_components = len(list(components_dir.glob("*/__init__.py"))) - if num_components > 100: + component_names = [p.parent.name for p in components_dir.glob("*/__init__.py")] + if len(component_names) > 100: # Prevent accidentally including all components from an esphome fork/branch # In this case force the user to manually specify which components they want to include raise cv.Invalid( @@ -102,6 +129,9 @@ def _process_single_config(config: dict[str, Any]) -> None: [CONF_COMPONENTS, i], ) allowed_components = config[CONF_COMPONENTS] + component_names = allowed_components + + _log_overridden_components(conf, component_names) loader.install_meta_finder(components_dir, allowed_components=allowed_components) 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/sensor.py b/esphome/components/ezo/sensor.py index b931885149..d1ee57a09b 100644 --- a/esphome/components/ezo/sensor.py +++ b/esphome/components/ezo/sensor.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -58,7 +59,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) 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/ezo_pmp/sensor.py b/esphome/components/ezo_pmp/sensor.py index a0473b292c..ed4efeeabc 100644 --- a/esphome/components/ezo_pmp/sensor.py +++ b/esphome/components/ezo_pmp/sensor.py @@ -23,8 +23,8 @@ CONF_PUMP_VOLTAGE = "pump_voltage" CONF_LAST_VOLUME_REQUESTED = "last_volume_requested" CONF_MAX_FLOW_RATE = "max_flow_rate" -UNIT_MILILITER = "ml" -UNIT_MILILITERS_PER_MINUTE = "ml/min" +UNIT_MILILITER = "mL" +UNIT_MILILITERS_PER_MINUTE = "mL/min" CONFIG_SCHEMA = cv.Schema( { diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index 818a53c0ed..a9064eb18f 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.final_validate import full_config +from esphome.types import ConfigType CODEOWNERS = ["@anatoly-savchenkov"] @@ -23,7 +24,7 @@ CONF_RESETS_REQUIRED = "resets_required" CONF_ON_INCREMENT = "on_increment" -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_RESETS_REQUIRED in config: return cv.only_on( [ @@ -60,14 +61,13 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if CORE.is_esp8266 and CONF_RESETS_REQUIRED in config: fconfig = full_config.get() if not fconfig.get_config_for_path([KEY_ESP8266, CONF_RESTORE_FROM_FLASH]): raise cv.Invalid( "'resets_required' needs 'restore_from_flash' to be enabled in the 'esp8266' configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate @@ -82,7 +82,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if reset_count := config.get(CONF_RESETS_REQUIRED): var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/factory_reset/button/__init__.py b/esphome/components/factory_reset/button/__init__.py index 61df5f297b..040614c151 100644 --- a/esphome/components/factory_reset/button/__init__.py +++ b/esphome/components/factory_reset/button/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import factory_reset_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = button.button_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await button.register_button(var, config) 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/__init__.py b/esphome/components/factory_reset/switch/__init__.py index a384a57f80..69a635a917 100644 --- a/esphome/components/factory_reset/switch/__init__.py +++ b/esphome/components/factory_reset/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT +from esphome.types import ConfigType from .. import factory_reset_ns @@ -17,6 +18,6 @@ CONFIG_SCHEMA = switch.switch_schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) 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/fan/fan.cpp b/esphome/components/fan/fan.cpp index 853bf94ffe..7dc0b5c6fe 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -153,11 +153,6 @@ void FanRestoreState::apply(Fan &fan) { fan.publish_state(); } -FanCall Fan::turn_on() { return this->make_call().set_state(true); } -FanCall Fan::turn_off() { return this->make_call().set_state(false); } -FanCall Fan::toggle() { return this->make_call().set_state(!this->state); } -FanCall Fan::make_call() { return FanCall(*this); } - const char *Fan::find_preset_mode_(const char *preset_mode) { return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0); } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 3d731e6eb0..106e6e74cd 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -115,10 +115,10 @@ class Fan : public EntityBase { /// The current direction of the fan FanDirection direction{FanDirection::FORWARD}; - FanCall turn_on(); - FanCall turn_off(); - FanCall toggle(); - FanCall make_call(); + FanCall turn_on() { return this->make_call().set_state(true); } + FanCall turn_off() { return this->make_call().set_state(false); } + FanCall toggle() { return this->make_call().set_state(!this->state); } + FanCall make_call() { return FanCall(*this); } /// Register a callback that will be called each time the state changes. template void add_on_state_callback(F &&callback) { diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index c944e8a930..e2fc8578cd 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -8,6 +8,8 @@ from esphome.const import ( CONF_RGB_ORDER, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] fastled_base_ns = cg.esphome_ns.namespace("fastled_base") @@ -34,17 +36,28 @@ BASE_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def new_fastled_light(config): +async def new_fastled_light(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await cg.register_component(var, config) if CONF_MAX_REFRESH_RATE in config: cg.add(var.set_max_refresh_rate(config[CONF_MAX_REFRESH_RATE])) - cg.add_library("fastled/FastLED", "3.9.16") if CORE.is_esp32: - from esphome.components.esp32 import include_builtin_idf_component + from esphome.components.esp32 import add_idf_component - include_builtin_idf_component("esp_lcd") + add_idf_component( + name="fastled/FastLED", + repo="https://github.com/FastLED/FastLED.git", + ref="d44c800a9e876a8394caefc2ce4915dd96dac77b", + ) + cg.add_library("SPI", None) + # FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with + # esphome's RMT channels (remote_transmitter etc., priority 0): the IDF + # driver rejects FastLED's channel and show() then hangs ~3s with no + # output. Override to 0 so it shares the interrupt. See #17063. + cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0") + else: + cg.add_library("fastled/FastLED", "3.9.16") await light.register_light(var, config) return var diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 8d1dd49dad..da4dbf2ed7 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "fastled_light.h" #include "esphome/core/log.h" @@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() { ESP_LOGCONFIG(TAG, "FastLED light:\n" " Num LEDs: %u\n" - " Max refresh rate: %u", + " Max refresh rate: %" PRIu32, this->num_leds_, this->max_refresh_rate_.value_or(0)); } void FastLEDLightOutput::write_state(light::LightState *state) { diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 8e87f67e6d..9f903b4530 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) #include "esphome/core/component.h" #include "esphome/core/helpers.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_; } @@ -143,7 +143,6 @@ class FastLEDLightOutput : public light::AddressableLight { } } -#ifdef FASTLED_HAS_CLOCKLESS template class CHIPSET, uint8_t DATA_PIN, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; @@ -160,7 +159,6 @@ class FastLEDLightOutput : public light::AddressableLight { static CHIPSET controller; return add_leds(&controller, num_leds); } -#endif template class CHIPSET, EOrder RGB_ORDER> CLEDController &add_leds(int num_leds) { static CHIPSET controller; diff --git a/esphome/components/fastled_clockless/light.py b/esphome/components/fastled_clockless/light.py index aa2172bf88..56c1ee93fa 100644 --- a/esphome/components/fastled_clockless/light.py +++ b/esphome/components/fastled_clockless/light.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_RGB_ORDER, Framework, ) +from esphome.types import ConfigType AUTO_LOAD = ["fastled_base"] @@ -41,7 +42,7 @@ CHIPSETS = [ ] -def _validate(value): +def _validate(value: ConfigType) -> ConfigType: if value[CONF_CHIPSET] == "NEOPIXEL" and CONF_RGB_ORDER in value: raise cv.Invalid("NEOPIXEL doesn't support RGB order") return value @@ -73,7 +74,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fastled_base.new_fastled_light(config) rgb_order = None diff --git a/esphome/components/fastled_spi/light.py b/esphome/components/fastled_spi/light.py index e863d33846..1c6b6e7148 100644 --- a/esphome/components/fastled_spi/light.py +++ b/esphome/components/fastled_spi/light.py @@ -11,6 +11,7 @@ from esphome.const import ( CONF_RGB_ORDER, Framework, ) +from esphome.types import ConfigType AUTO_LOAD = ["fastled_base"] @@ -52,7 +53,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fastled_base.new_fastled_light(config) rgb_order = cg.RawExpression(config.get(CONF_RGB_ORDER, "RGB")) diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 856818280f..032d01c8e5 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -14,6 +14,7 @@ from esphome.const import ( CONF_STOP_ACTION, CONF_UPDATE_INTERVAL, ) +from esphome.types import ConfigType CONF_OPEN_SENSOR = "open_sensor" CONF_CLOSE_SENSOR = "close_sensor" @@ -29,7 +30,7 @@ endstop_ns = cg.esphome_ns.namespace("feedback") FeedbackCover = endstop_ns.class_("FeedbackCover", cover.Cover, cg.Component) -def validate_infer_endstop(config): +def validate_infer_endstop(config: ConfigType) -> ConfigType: if config[CONF_INFER_ENDSTOP_FROM_MOVEMENT] is True: if config[CONF_HAS_BUILT_IN_ENDSTOP] is False: raise cv.Invalid( @@ -95,7 +96,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) 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..7cef7c754a --- /dev/null +++ b/esphome/components/file/image.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +import contextlib +import io +import logging +from pathlib import Path +import re +from typing import Any + +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_byte_order, + 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.external_files import RemoteFile +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +_LOGGER = logging.getLogger(__name__) + +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/", +} + +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") + + +def compute_local_image_path(value: str | ConfigType) -> Path: + url = value[CONF_URL] if isinstance(value, dict) else value + # Downloaded files are cached under the shared `image` domain directory so + # the cache location is unaffected by which platform requested the file. + return external_files.compute_local_file_path(DOMAIN, url) + + +def local_path(value: str | ConfigType) -> str: + value = value[CONF_PATH] if isinstance(value, dict) else value + return str(CORE.relative_config_path(value)) + + +def download_file(url: str, path: Path) -> str: + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) + return str(path) + + +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: + base_dir = external_files.compute_local_file_dir(DOMAIN) / source + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" + + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) + return download_file(url, path) + + +def download_image(value: str | ConfigType) -> str: + value = value[CONF_URL] if isinstance(value, dict) else value + return download_file(value, compute_local_image_path(value)) + + +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ + parts = value.strip().split(":") + if len(parts) == 2 and parts[0] in MDI_SOURCES: + if _MDI_ICON_RE.match(parts[1]) is None: + raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) + if value.startswith(("http://", "https://")): + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value: Any) -> str: + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) + + value = cv.file_(value) + return local_path(value) + + +LOCAL_SCHEMA = cv.All( + { + cv.Required(CONF_PATH): cv.file_, + }, + local_path, +) + + +def mdi_schema(source: str) -> cv.All: + def validate_mdi(value: ConfigType) -> str: + 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): validate_byte_order, + 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) -> None: + """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" + + +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: ConfigType, all_frames: bool = False +) -> tuple[MockObj, int, int, MockObj, MockObj, int]: + 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.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 3f57789034..b38d42191b 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() { break; case ENROLL_MISMATCH: ESP_LOGE(TAG, "Scans do not match"); + [[fallthrough]]; default: return this->data_[0]; } 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/__init__.py b/esphome/components/font/__init__.py index a10c45a9d7..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,6 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -295,45 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" ) from e - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -344,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -356,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -385,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith("http://") or value.startswith("https://"): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( @@ -563,13 +689,13 @@ async def to_code(config): point_set.update(flatten(config[CONF_GLYPHS])) # Create the codepoint to font file map base_font = FONT_CACHE[config[CONF_FILE]] - point_font_map: dict[str, Face] = {c: base_font for c in point_set} + point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font) # process extras, updating the map and extending the codepoint list for extra in config[CONF_EXTRAS]: extra_points = flatten(extra[CONF_GLYPHS]) point_set.update(extra_points) extra_font = FONT_CACHE[extra[CONF_FILE]] - point_font_map.update({c: extra_font for c in extra_points}) + point_font_map.update(dict.fromkeys(extra_points, extra_font)) codepoints = list(point_set) codepoints.sort(key=functools.cmp_to_key(glyph_comparator)) @@ -594,7 +720,9 @@ async def to_code(config): x.height, ] for (x, y) in zip( - glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args])) + glyph_args, + list(accumulate([len(x.bitmap_data) for x in glyph_args])), + strict=True, ) ] 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/fs3000/sensor.py b/esphome/components/fs3000/sensor.py index a168a36c31..8c389a2593 100644 --- a/esphome/components/fs3000/sensor.py +++ b/esphome/components/fs3000/sensor.py @@ -4,6 +4,7 @@ import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv from esphome.const import CONF_MODEL, DEVICE_CLASS_WIND_SPEED, STATE_CLASS_MEASUREMENT +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@kahrendt"] @@ -38,7 +39,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ft5x06/touchscreen/__init__.py b/esphome/components/ft5x06/touchscreen/__init__.py index e94791da4e..3ebff693c0 100644 --- a/esphome/components/ft5x06/touchscreen/__init__.py +++ b/esphome/components/ft5x06/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 +from esphome.types import ConfigType from .. import ft5x06_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x48)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await i2c.register_i2c_device(var, config) await touchscreen.register_touchscreen(var, config) diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 835dc4aac0..24d3529fb4 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() { this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } + // reading the chip registers to get max x/y does not seem to work. + if (this->display_ != nullptr) { + 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(); + } + } + // wait 200ms after reset. this->set_timeout(200, [this] { this->continue_setup_(); }); } @@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() { this->mark_failed(); return; } - // reading the chip registers to get max x/y does not seem to work. - if (this->display_ != nullptr) { - 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(); - } - } } void FT5x06Touchscreen::update_touches() { @@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() { uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]); uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]); - ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); + ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y); if (status == 0 || status == 2) { this->add_raw_touch_position_(id, x, y); } 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/ft63x6/touchscreen.py b/esphome/components/ft63x6/touchscreen.py index 7615b3046f..0d8537bde9 100644 --- a/esphome/components/ft63x6/touchscreen.py +++ b/esphome/components/ft63x6/touchscreen.py @@ -3,6 +3,7 @@ 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, CONF_THRESHOLD +from esphome.types import ConfigType CODEOWNERS = ["@gpambrozio"] DEPENDENCIES = ["i2c"] @@ -31,7 +32,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/fujitsu_general/climate.py b/esphome/components/fujitsu_general/climate.py index a104eafbcc..c2c41730e3 100644 --- a/esphome/components/fujitsu_general/climate.py +++ b/esphome/components/fujitsu_general/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -11,5 +12,5 @@ FujitsuGeneralClimate = fujitsu_general_ns.class_( CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FujitsuGeneralClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index e4de7721c6..49907443ff 100644 --- a/esphome/components/gcja5/sensor.py +++ b/esphome/components/gcja5/sensor.py @@ -18,6 +18,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_MICROGRAMS_PER_CUBIC_METER, ) +from esphome.types import ConfigType CODEOWNERS = ["@gcormier"] DEPENDENCIES = ["uart"] @@ -111,7 +112,7 @@ TYPES = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index 878f27bc44..f98af3f863 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/gdk101/binary_sensor.py b/esphome/components/gdk101/binary_sensor.py index a80487977f..14f5fa0e1c 100644 --- a/esphome/components/gdk101/binary_sensor.py +++ b/esphome/components/gdk101/binary_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_DIAGNOSTIC, ICON_VIBRATE, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await binary_sensor.new_binary_sensor(config[CONF_VIBRATIONS]) cg.add(hub.set_vibration_binary_sensor(var)) 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/gdk101/sensor.py b/esphome/components/gdk101/sensor.py index 6cf89e0fd4..4ed081a7be 100644 --- a/esphome/components/gdk101/sensor.py +++ b/esphome/components/gdk101/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_MICROSILVERTS_PER_HOUR, UNIT_SECOND, ) +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -59,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) if radiation_dose_per_1m := config.get(CONF_RADIATION_DOSE_PER_1M): diff --git a/esphome/components/gdk101/text_sensor.py b/esphome/components/gdk101/text_sensor.py index 703e68493a..bdef2466df 100644 --- a/esphome/components/gdk101/text_sensor.py +++ b/esphome/components/gdk101/text_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from . import CONF_GDK101_ID, GDK101Component @@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_GDK101_ID]) var = await text_sensor.new_text_sensor(config[CONF_VERSION]) cg.add(hub.set_fw_version_text_sensor(var)) 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/gl_r01_i2c/sensor.py b/esphome/components/gl_r01_i2c/sensor.py index 6a8d47213c..73f7339e66 100644 --- a/esphome/components/gl_r01_i2c/sensor.py +++ b/esphome/components/gl_r01_i2c/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_MILLIMETER, ) +from esphome.types import ConfigType CODEOWNERS = ["@pkejval"] DEPENDENCIES = ["i2c"] @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await sensor.register_sensor(var, config) diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 46725fe6dd..bd6bc5f783 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -8,7 +8,8 @@ from esphome.const import ( CONF_TYPE, CONF_VALUE, ) -from esphome.core import CoroPriority, coroutine_with_priority +from esphome.core import ID, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -62,7 +63,7 @@ CONFIG_SCHEMA = _globals_schema # Run with low priority so that namespaces are registered first @coroutine_with_priority(CoroPriority.LATE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: type_ = cg.RawExpression(config[CONF_TYPE]) restore = config[CONF_RESTORE_VALUE] @@ -104,7 +105,12 @@ async def to_code(config): ), synchronous=True, ) -async def globals_set_to_code(config, action_id, template_arg, args): +async def globals_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID]) template_arg = cg.TemplateArguments(full_id.type, *template_arg) var = cg.new_Pvariable(action_id, template_arg, paren) 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/gp2y1010au0f/sensor.py b/esphome/components/gp2y1010au0f/sensor.py index 4ff8a38226..3121aa1de5 100644 --- a/esphome/components/gp2y1010au0f/sensor.py +++ b/esphome/components/gp2y1010au0f/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_MICROGRAMS_PER_CUBIC_METER, ) +from esphome.types import ConfigType DEPENDENCIES = ["output"] AUTO_LOAD = ["voltage_sampler"] @@ -43,7 +44,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 83859a4030..17c88b6875 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODEL, CONF_VOLTAGE +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@sebydocky"] DEPENDENCIES = ["i2c"] @@ -38,7 +39,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/gp8403/output/__init__.py index 5245c405db..432f387b1b 100644 --- a/esphome/components/gp8403/output/__init__.py +++ b/esphome/components/gp8403/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType from .. import CONF_GP8403_ID, GP8403Component, gp8403_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await output.register_output(var, config) 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 390b26ba1d..8a40e4e732 100644 --- a/esphome/components/gpio/binary_sensor/__init__.py +++ b/esphome/components/gpio/binary_sensor/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( CONF_PIN, ) from esphome.core import CORE +from esphome.types import ConfigType from .. import gpio_ns @@ -39,7 +40,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 +47,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( @@ -69,12 +69,10 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool: return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: use_interrupt = config[CONF_USE_INTERRUPT] if not use_interrupt: - return config - - pin_num = config[CONF_PIN][CONF_NUMBER] + return # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. @@ -85,7 +83,9 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return + + pin_num = config[CONF_PIN][CONF_NUMBER] # GPIO16 on ESP8266 doesn't support interrupts through attachInterrupt(). if CORE.is_esp8266 and pin_num == 16: @@ -97,7 +97,7 @@ def _final_validate(config): config.get(CONF_NAME, config[CONF_ID]), ) config[CONF_USE_INTERRUPT] = False - return config + return # When a pin is shared, interrupts can interfere with other components # (e.g., duty_cycle sensor) that need to monitor the pin's state changes. @@ -121,13 +121,11 @@ def _final_validate(config): pin_num, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) @@ -135,6 +133,7 @@ async def to_code(config): cg.add(var.set_pin(pin)) if config[CONF_USE_INTERRUPT]: + cg.add_define("USE_GPIO_BINARY_SENSOR_INTERRUPT") cg.add(var.set_interrupt_type(config[CONF_INTERRUPT_TYPE])) else: cg.add(var.set_use_interrupt(False)) diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index ff07d76901..9d044dca2d 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -7,6 +7,7 @@ namespace esphome::gpio { static const char *const TAG = "gpio.binary_sensor"; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT // Interrupt type strings indexed by edge-triggered InterruptType values: // indices 1-3: RISING_EDGE, FALLING_EDGE, ANY_EDGE; other values (e.g. level-triggered) map to UNKNOWN (index 0). PROGMEM_STRING_TABLE(InterruptTypeStrings, "UNKNOWN", "RISING_EDGE", "FALLING_EDGE", "ANY_EDGE"); @@ -19,7 +20,9 @@ static const LogString *gpio_mode_to_string(bool use_interrupt) { return use_interrupt ? LOG_STR("interrupt") : LOG_STR("polling"); } #endif +#endif +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT void IRAM_ATTR GPIOBinarySensorStore::gpio_intr(GPIOBinarySensorStore *arg) { bool new_state = arg->isr_pin_.digital_read(); if (new_state != arg->state_) { @@ -43,28 +46,36 @@ void GPIOBinarySensorStore::setup(InternalGPIOPin *pin, Component *component) { // Attach interrupt - from this point on, any changes will be caught by the interrupt pin->attach_interrupt(&GPIOBinarySensorStore::gpio_intr, this, this->interrupt_type_); } +#endif // USE_GPIO_BINARY_SENSOR_INTERRUPT void GPIOBinarySensor::setup() { +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT if (this->store_.use_interrupt_) { auto *internal_pin = static_cast(this->pin_); this->store_.setup(internal_pin, this); this->publish_initial_state(this->store_.get_state()); - } else { - this->pin_->setup(); - this->publish_initial_state(this->pin_->digital_read()); + return; } +#endif + this->pin_->setup(); + this->publish_initial_state(this->pin_->digital_read()); } void GPIOBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "GPIO Binary Sensor", this); LOG_PIN(" Pin: ", this->pin_); +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT ESP_LOGCONFIG(TAG, " Mode: %s", LOG_STR_ARG(gpio_mode_to_string(this->store_.use_interrupt_))); if (this->store_.use_interrupt_) { ESP_LOGCONFIG(TAG, " Interrupt Type: %s", LOG_STR_ARG(interrupt_type_to_string(this->store_.interrupt_type_))); } +#else + ESP_LOGCONFIG(TAG, " Mode: polling"); +#endif } void GPIOBinarySensor::loop() { +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT if (this->store_.use_interrupt_) { if (this->store_.is_changed()) { // Clear the flag immediately to minimize the window where we might miss changes @@ -78,9 +89,10 @@ void GPIOBinarySensor::loop() { // No changes, disable the loop until the next interrupt this->disable_loop(); } - } else { - this->publish_state(this->pin_->digital_read()); + return; } +#endif + this->publish_state(this->pin_->digital_read()); } float GPIOBinarySensor::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 100edb4cca..956443fab5 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" @@ -10,6 +11,7 @@ namespace esphome::gpio { // Store class for ISR data and configuration (no vtables, ISR-safe) class GPIOBinarySensorStore { public: +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT void setup(InternalGPIOPin *pin, Component *component); static void gpio_intr(GPIOBinarySensorStore *arg); @@ -29,15 +31,18 @@ class GPIOBinarySensorStore { // Separate method to clear the flag this->changed_ = false; } +#endif protected: friend class GPIOBinarySensor; +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT ISRInternalGPIOPin isr_pin_; Component *component_{nullptr}; // Pointer to the component for enable_loop_soon_any_context() volatile bool state_{false}; volatile bool changed_{false}; - bool use_interrupt_{true}; gpio::InterruptType interrupt_type_{gpio::INTERRUPT_ANY_EDGE}; + bool use_interrupt_{true}; +#endif }; class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { @@ -46,8 +51,14 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon // Interrupts are only detached on reboot when memory is cleared anyway. void set_pin(GPIOPin *pin) { this->pin_ = pin; } +#ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT void set_use_interrupt(bool use_interrupt) { this->store_.use_interrupt_ = use_interrupt; } void set_interrupt_type(gpio::InterruptType type) { this->store_.interrupt_type_ = type; } +#else + // Polling-only build: codegen still emits set_use_interrupt(false) calls, + // so keep the setter as an inlined no-op instead of storing the flag. + void set_use_interrupt(bool /*use_interrupt*/) {} +#endif // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup pin diff --git a/esphome/components/gpio/one_wire/__init__.py b/esphome/components/gpio/one_wire/__init__.py index e2bb94dd66..feb8b53dff 100644 --- a/esphome/components/gpio/one_wire/__init__.py +++ b/esphome/components/gpio/one_wire/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components.one_wire import OneWireBus import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -18,7 +19,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index 1fecfbf0dd..f445efeca3 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -55,8 +55,11 @@ int HOT IRAM_ATTR GPIOOneWireBus::reset_int() { delayMicroseconds(1); } - // delay J - delayMicroseconds(start + 480 - micros()); + // delay J: finish the 480us slot, but never spin if it already elapsed + // (unsigned wrap here would busy-wait for minutes with interrupts off) + uint32_t elapsed = micros() - start; + if (elapsed < 480) + delayMicroseconds(480 - elapsed); this->pin_.digital_write(true); this->pin_.pin_mode(gpio::FLAG_OUTPUT); return r ? 1 : 0; 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/__init__.py b/esphome/components/gpio/output/__init__.py index 786e04bac0..ab242c643f 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -16,7 +17,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) await cg.register_component(var, config) 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/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 9462cd0161..2e0b0969bc 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_INTERLOCK, CONF_PIN +from esphome.types import ConfigType from .. import gpio_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index e69de29bb2..0c7199b6df 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -0,0 +1,22 @@ +from esphome import pins +import esphome.config_validation as cv +from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED +from esphome.types import ConfigType + + +def validate_interrupt_pin(value: ConfigType) -> ConfigType: + # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) + # and install a single ISR per GPIO, so neither inversion nor sharing is supported. + value = pins.internal_gpio_input_pin_schema(value) + if value.get(CONF_INVERTED): + raise cv.Invalid( + f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "the expander INT line is fixed active-low" + ) + if value.get(CONF_ALLOW_OTHER_USES): + raise cv.Invalid( + f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " + "sharing the interrupt pin between multiple components is not implemented. " + f"Remove the '{CONF_INTERRUPT_PIN}' to fall back to polling." + ) + return value diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index ab48417a4e..94a36a0afa 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_KILOMETER_PER_HOUR, UNIT_METER, ) +from esphome.types import ConfigType CONF_GPS_ID = "gps_id" CONF_HDOP = "hdop" @@ -93,7 +94,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("gps", require_rx=True) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/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/__init__.py b/esphome/components/gps/time/__init__.py index bdeeb86e00..faa06e6ae3 100644 --- a/esphome/components/gps/time/__init__.py +++ b/esphome/components/gps/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import CONF_GPS_ID, GPS, GPSListener, gps_ns @@ -19,7 +20,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.polling_component_schema("5min")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await time_.register_time(var, config) await cg.register_component(var, config) 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/__init__.py b/esphome/components/graph/__init__.py index 0749d7e2a3..1b99491f9c 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -29,6 +29,7 @@ from esphome.const import ( CONF_X_GRID, CONF_Y_GRID, ) +from esphome.types import ConfigType CODEOWNERS = ["@synco"] @@ -115,7 +116,9 @@ GRAPH_SCHEMA = cv.Schema( ) -def _relocate_fields_to_subfolder(config, subfolder, subschema): +def _relocate_fields_to_subfolder( + config: ConfigType, subfolder: str, subschema: cv.Schema +) -> ConfigType: fields = [k.schema for k in subschema.schema] fields.remove(CONF_ID) if subfolder in config: @@ -138,7 +141,7 @@ def _relocate_fields_to_subfolder(config, subfolder, subschema): return config -def _relocate_trace(config): +def _relocate_trace(config: ConfigType) -> ConfigType: return _relocate_fields_to_subfolder(config, CONF_TRACES, GRAPH_TRACE_SCHEMA) @@ -148,7 +151,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_duration(config[CONF_DURATION])) cg.add(var.set_width(config[CONF_WIDTH])) 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/__init__.py b/esphome/components/graphical_display_menu/__init__.py index 56b720e75c..668a0d74d1 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( CONF_ID, CONF_TRIGGER_ID, ) +from esphome.types import ConfigType CONF_MENU_ITEM_VALUE = "menu_item_value" CONF_ON_REDRAW = "on_redraw" @@ -59,7 +60,7 @@ CONFIG_SCHEMA = DISPLAY_MENU_BASE_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/climate.py b/esphome/components/gree/climate.py index 0892155fd2..356845a7a6 100644 --- a/esphome/components/gree/climate.py +++ b/esphome/components/gree/climate.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate_ir import esphome.config_validation as cv from esphome.const import CONF_MODEL +from esphome.types import ConfigType from . import gree_ns @@ -28,6 +29,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 705c741dd0..a794e7721f 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -5,7 +5,23 @@ namespace esphome::gree { static const char *const TAG = "gree.climate"; +climate::ClimateTraits GreeClimate::traits() { + auto t = climate_ir::ClimateIR::traits(); + // ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported. + if (!this->supports_heat_) { + auto modes = t.get_supported_modes(); + modes.erase(climate::CLIMATE_MODE_HEAT_COOL); + t.set_supported_modes(modes); + } + return t; +} + void GreeClimate::set_model(Model model) { + if (model == GREE_YAN) { + // YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model. + this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL); + this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH); + } if (model == GREE_YX1FF) { this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index 24453750ae..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, @@ -94,6 +94,7 @@ class GreeClimate : public climate_ir::ClimateIR { protected: // Transmit via IR the state of this climate controller. void transmit_state() override; + climate::ClimateTraits traits() override; uint8_t operation_mode_(); uint8_t fan_speed_(); diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 111fea65d2..9bec3751d6 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -3,6 +3,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_LIGHT, DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG import esphome.final_validate as fv +from esphome.types import ConfigType from .. import gree_ns from ..climate import CONF_MODEL, GreeClimate @@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def _validate_model(config): +def _validate_model(config: ConfigType) -> None: full_config = fv.full_config.get() climate_path = full_config.get_path_for_id(config[CONF_GREE_ID])[:-1] climate_conf = full_config.get_config_for_path(climate_path) @@ -63,7 +64,7 @@ def _validate_model(config): FINAL_VALIDATE_SCHEMA = _validate_model -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_GREE_ID]) for conf_key, name, bit_mask, _ in SWITCH_CONFIGS: 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_gas_mc_v2/sensor.py b/esphome/components/grove_gas_mc_v2/sensor.py index 0c35047850..da687c4cd3 100644 --- a/esphome/components/grove_gas_mc_v2/sensor.py +++ b/esphome/components/grove_gas_mc_v2/sensor.py @@ -18,6 +18,7 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType CODEOWNERS = ["@YorkshireIoT"] DEPENDENCIES = ["i2c"] @@ -66,7 +67,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 41beb6e4e9..d2102496a2 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -7,7 +7,6 @@ namespace esphome::growatt_solar { static const char *const TAG = "growatt_solar"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x04; static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion void GrowattSolar::loop() { @@ -31,11 +30,12 @@ void GrowattSolar::update() { } this->waiting_to_update_ = false; - this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT[this->protocol_version_]); + this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); this->last_send_ = millis(); } -void GrowattSolar::on_modbus_data(const std::vector &data) { +void GrowattSolar::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); // Other components might be sending commands to our device. But we don't get called with enough // context to know what is what. So if we didn't do a send, we ignore the data. if (!this->last_send_) @@ -63,71 +63,88 @@ void GrowattSolar::on_modbus_data(const std::vector &data) { switch (this->protocol_version_) { case RTU: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 11, 12, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 13, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 14, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 15, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 16, 17, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, + RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 18, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 19, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 20, 21, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, + RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 22, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 23, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 24, 25, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, + RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 26, 27, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 28, 29, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, + RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 32, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } case RTU2: { - publish_1_reg_sensor_state(this->inverter_status_, 0, 1); + publish_1_reg_sensor_state(this->inverter_status_, RTU2_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, 1, 2, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, 3, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, 4, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, 5, 6, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, 7, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, 8, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, 9, 10, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1, + ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, 35, 36, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->grid_frequency_sensor_, 37, TWO_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1, + ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, 38, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[0].current_sensor_, 39, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, 40, 41, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, + RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, 42, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[1].current_sensor_, 43, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, 44, 45, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, + RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, 46, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->phases_[2].current_sensor_, 47, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, 48, 49, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, + RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, 53, 54, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, 55, 56, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1, + ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, + RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_1_reg_sensor_state(this->inverter_module_temp_, 93, ONE_DEC_UNIT); + publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; } } diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index 7eba795601..a172f49001 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -4,7 +4,7 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include namespace esphome::growatt_solar { @@ -16,11 +16,60 @@ enum GrowattProtocolVersion { RTU2, }; -class GrowattSolar : public PollingComponent, public modbus::ModbusDevice { +// Register addresses for the RTU protocol. +constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 +constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1 +constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1 +constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1 +constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 +constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1 +constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1 +constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 +constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1 +constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1 +constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 +constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2 +constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 +constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1 + +// Input register addresses for the RTU2 protocol as described +// in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document. +constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1 +constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 +constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1 +constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1 +constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1 +constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1 +constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 +constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1 +constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 +constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1 +constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 +constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 +constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1 +constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 +constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 +constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1 +constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 +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 final : public PollingComponent, public modbus::ModbusClientDevice { public: void loop() override; void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; } diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 7458b88b72..2e2b218730 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 = ( ) -async def to_code(config): +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("growatt_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: 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..703887864b --- /dev/null +++ b/esphome/components/gsl3670/touchscreen.py @@ -0,0 +1,242 @@ +"""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 +from esphome.external_files import RemoteFile +from esphome.types import ConfigType + +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", + }, + }, + "GUITION-JC8012P4A1": { + CONF_SWAP_XY: True, + CONF_MIRROR_X: True, + CONF_MIRROR_Y: False, + CONF_X_MIN: 20, + CONF_Y_MIN: 20, + CONF_X_MAX: 880, + CONF_Y_MAX: 1648, + CONF_RESET_PIN: 22, + CONF_INTERRUPT_PIN: 21, + 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.""" + return external_files.compute_local_file_path(DOMAIN, url) + + +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 _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + +def _config_schema(config: ConfigType) -> ConfigType: + 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: ConfigType) -> 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: ConfigType) -> None: + 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/__init__.py b/esphome/components/gt911/binary_sensor/__init__.py index 941b7bb847..95c072977e 100644 --- a/esphome/components/gt911/binary_sensor/__init__.py +++ b/esphome/components/gt911/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_INDEX +from esphome.types import ConfigType from .. import gt911_ns from ..touchscreen import GT911ButtonListener, GT911Touchscreen @@ -24,7 +25,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(GT911Button).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_GT911_ID]) 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/__init__.py b/esphome/components/gt911/touchscreen/__init__.py index b850eeea8b..fa929d4ba0 100644 --- a/esphome/components/gt911/touchscreen/__init__.py +++ b/esphome/components/gt911/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 esphome.types import ConfigType from .. import gt911_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x5D)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) 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/climate.py b/esphome/components/haier/climate.py index 424ef46392..70ae36f528 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -424,7 +424,7 @@ async def power_action_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() if CONF_LOGGER in full_config: _level = "NONE" @@ -448,7 +448,6 @@ def _final_validate(config): raise cv.Invalid( f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 74a218263d..294aa53b03 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -132,7 +132,7 @@ void HaierClimateBase::save_settings() { } bool HaierClimateBase::get_display_state() const { - return (this->display_status_ == SwitchState::ON) || (this->display_status_ == SwitchState::PENDING_ON); + return (this->display_status_ == SwitchState::SWITCH_ON) || (this->display_status_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_display_state(bool state) { @@ -144,7 +144,7 @@ void HaierClimateBase::set_display_state(bool state) { } bool HaierClimateBase::get_health_mode() const { - return (this->health_mode_ == SwitchState::ON) || (this->health_mode_ == SwitchState::PENDING_ON); + return (this->health_mode_ == SwitchState::SWITCH_ON) || (this->health_mode_ == SwitchState::PENDING_ON); } void HaierClimateBase::set_health_mode(bool state) { diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index 13e8d7548d..db4c1abceb 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -147,8 +147,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional message; }; enum class SwitchState { - OFF = 0b00, - ON = 0b01, + SWITCH_OFF = 0b00, + SWITCH_ON = 0b01, PENDING_OFF = 0b10, PENDING_ON = 0b11, }; @@ -157,8 +157,8 @@ class HaierClimateBase : public esphome::Component, esphome::optional action_request_; uint8_t fan_mode_speed_; uint8_t other_modes_fan_speed_; - SwitchState display_status_{SwitchState::ON}; - SwitchState health_mode_{SwitchState::OFF}; + SwitchState display_status_{SwitchState::SWITCH_ON}; + SwitchState health_mode_{SwitchState::SWITCH_OFF}; bool force_send_control_; bool forced_request_status_; bool reset_protocol_request_; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ad9b00ce4..881a2328cb 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -50,7 +50,7 @@ void HonClimate::set_quiet_mode_state(bool state) { this->quiet_mode_state_ = state ? SwitchState::PENDING_ON : SwitchState::PENDING_OFF; this->force_send_control_ = true; } else { - this->quiet_mode_state_ = state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } this->settings_.quiet_mode_state = state; #ifdef USE_SWITCH @@ -63,7 +63,7 @@ void HonClimate::set_quiet_mode_state(bool state) { } bool HonClimate::get_quiet_mode_state() const { - return (this->quiet_mode_state_ == SwitchState::ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); + return (this->quiet_mode_state_ == SwitchState::SWITCH_ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON); } esphome::optional HonClimate::get_vertical_airflow() const { @@ -513,7 +513,7 @@ void HonClimate::initialization() { } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; - this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } haier_protocol::HaierMessage HonClimate::get_control_message() { @@ -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 @@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * #ifdef USE_SENSOR this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20); this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); - this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64); + this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64); this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64); this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1])); @@ -939,14 +939,14 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->display_status_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { @@ -1008,7 +1008,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // In proper mode and not in pending state bool new_quiet_mode = packet.control.quiet_mode != 0; if (new_quiet_mode != this->get_quiet_mode_state()) { - this->quiet_mode_state_ = new_quiet_mode ? SwitchState::ON : SwitchState::OFF; + this->quiet_mode_state_ = new_quiet_mode ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; this->settings_.quiet_mode_state = new_quiet_mode; #ifdef USE_SWITCH if (this->quiet_mode_switch_ != nullptr) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index 5b477a5cea..a34b4422c6 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 { @@ -197,7 +197,7 @@ class HonClimate : public HaierClimateBase { esphome::optional current_horizontal_swing_{}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; - SwitchState quiet_mode_state_{SwitchState::OFF}; + SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; }; } // namespace esphome::haier diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index bd5678a425..fdb3b779e2 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 @@ -464,14 +464,14 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin // AC just turned on from remote need to turn off display this->force_send_control_ = true; } else if ((((uint8_t) this->health_mode_) & 0b10) == 0) { - this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF; + this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; } } } // Health mode if ((((uint8_t) this->health_mode_) & 0b10) == 0) { bool old_health_mode = this->get_health_mode(); - this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF; + this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; should_publish = should_publish || (old_health_mode != this->get_health_mode()); } { 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/__init__.py b/esphome/components/haier/switch/__init__.py index acff0cf265..99ffcb37af 100644 --- a/esphome/components/haier/switch/__init__.py +++ b/esphome/components/haier/switch/__init__.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = cv.Schema( ) -def _final_validate(config): +def _final_validate(config) -> None: full_config = fv.full_config.get() for switch_type in [CONF_BEEPER, CONF_QUIET_MODE]: # Check switches that are only supported for HonClimate @@ -72,7 +72,6 @@ def _final_validate(config): raise cv.Invalid( f"{switch_type} switch is only supported for hon climate" ) - return config FINAL_VALIDATE_SCHEMA = _final_validate 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.cpp b/esphome/components/havells_solar/havells_solar.cpp index 9257a37fd9..6af72c352b 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -7,10 +7,10 @@ namespace esphome::havells_solar { static const char *const TAG = "havells_solar"; -static const uint8_t MODBUS_CMD_READ_IN_REGISTERS = 0x03; static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers -void HavellsSolar::on_modbus_data(const std::vector &data) { +void HavellsSolar::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); if (data.size() < MODBUS_REGISTER_COUNT * 2) { ESP_LOGW(TAG, "Invalid size for HavellsSolar!"); return; @@ -121,7 +121,7 @@ void HavellsSolar::on_modbus_data(const std::vector &data) { this->dci_of_t_sensor_->publish_state(dci_of_t); } -void HavellsSolar::update() { this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT); } +void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); } void HavellsSolar::dump_config() { ESP_LOGCONFIG(TAG, "HAVELLS Solar:\n" diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index c54b0dcf14..ed5d13b8b6 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -4,11 +4,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" -#include +#include 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; @@ -77,7 +77,7 @@ class HavellsSolar : public PollingComponent, public modbus::ModbusDevice { void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index f0683e1d9c..dcea1afd04 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 = ( ) -async def to_code(config): +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("havells_solar", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: 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/__init__.py b/esphome/components/hbridge/fan/__init__.py index 8ea8677ba2..2cf1693b47 100644 --- a/esphome/components/hbridge/fan/__init__.py +++ b/esphome/components/hbridge/fan/__init__.py @@ -13,6 +13,9 @@ from esphome.const import ( CONF_PRESET_MODES, CONF_SPEED_COUNT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import hbridge_ns @@ -54,12 +57,17 @@ CONFIG_SCHEMA = ( maybe_simple_id({cv.GenerateID(): cv.use_id(HBridgeFan)}), synchronous=True, ) -async def fan_hbridge_brake_to_code(config, action_id, template_arg, args): +async def fan_hbridge_brake_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await fan.new_fan( config, config[CONF_SPEED_COUNT], 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/light/__init__.py b/esphome/components/hbridge/light/__init__.py index ccb47237b6..f7866cb990 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -1,14 +1,15 @@ import esphome.codegen as cg from esphome.components import light, output import esphome.config_validation as cv -from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B +from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.Component, light.LightOutput + "HBridgeLightOutput", cg.PollingComponent, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( @@ -16,12 +17,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput), cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput), cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput), + cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval, } ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL))) await cg.register_component(var, config) await light.register_light(var, config) diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index 16408f24f1..9dcf7adfd8 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -3,11 +3,10 @@ #include "esphome/components/light/light_output.h" #include "esphome/components/output/float_output.h" #include "esphome/core/component.h" -#include "esphome/core/helpers.h" namespace esphome::hbridge { -class HBridgeLightOutput : public Component, public light::LightOutput { +class HBridgeLightOutput final : public PollingComponent, public light::LightOutput { public: void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; } void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; } @@ -20,11 +19,12 @@ class HBridgeLightOutput : public Component, public light::LightOutput { return traits; } - void setup() override { this->disable_loop(); } + void setup() override { this->stop_poller(); } - void loop() override { - // Only called when both channels are active — alternate H-bridge direction - // each iteration to multiplex cold and warm white. + void update() override { + // Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay + // slower than the output's PWM period (flipping faster collapses the output onto one + // channel) but fast enough to avoid flicker (issue #17030). if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); @@ -46,13 +46,17 @@ class HBridgeLightOutput : public Component, public light::LightOutput { this->pinb_duty_ = new_pinb; if (new_pina != 0.0f && new_pinb != 0.0f) { - // Both channels active — need loop to alternate H-bridge direction - this->high_freq_.start(); - this->enable_loop(); + // Both channels active — multiplex the H-bridge direction via the poller. + if (!this->multiplexing_) { + this->multiplexing_ = true; + this->start_poller(); + } } else { - // Zero or one channel active — drive pins directly, no multiplexing needed - this->high_freq_.stop(); - this->disable_loop(); + // Zero or one channel active — drive pins directly, no multiplexing needed. + if (this->multiplexing_) { + this->multiplexing_ = false; + this->stop_poller(); + } this->pina_pin_->set_level(new_pina); this->pinb_pin_->set_level(new_pinb); } @@ -64,7 +68,7 @@ class HBridgeLightOutput : public Component, public light::LightOutput { float pina_duty_{0}; float pinb_duty_{0}; bool forward_direction_{false}; - HighFrequencyLoopRequester high_freq_; + bool multiplexing_{false}; }; } // namespace esphome::hbridge diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index e26bd6b1d8..294be6ed5f 100644 --- a/esphome/components/hbridge/switch/__init__.py +++ b/esphome/components/hbridge/switch/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_OPTIMISTIC, CONF_PULSE_LENGTH, CONF_WAIT_TIME +from esphome.types import ConfigType from .. import hbridge_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) 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/hc8/sensor.py b/esphome/components/hc8/sensor.py index 29b428e310..616162eb40 100644 --- a/esphome/components/hc8/sensor.py +++ b/esphome/components/hc8/sensor.py @@ -12,6 +12,9 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -47,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -73,7 +76,12 @@ CALIBRATION_ACTION_SCHEMA = cv.Schema( CALIBRATION_ACTION_SCHEMA, synchronous=True, ) -async def hc8_calibration_to_code(config, action_id, template_arg, args): +async def hc8_calibration_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) 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/hdc1080/sensor.py b/esphome/components/hdc1080/sensor.py index e47a88545b..b2b6dc533a 100644 --- a/esphome/components/hdc1080/sensor.py +++ b/esphome/components/hdc1080/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py index 15e19f2cc8..ad0311fb4f 100644 --- a/esphome/components/hdc2010/sensor.py +++ b/esphome/components/hdc2010/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index dcb207e099..bf3a4bc79f 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only void HDC2080Component::setup() { const uint8_t data = 0x00; // automatic measurement mode disabled, heater off if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } 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/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index 777fc51cba..b5388b4c2b 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -43,7 +44,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index a6265b9b98..6d91c3df7c 100644 --- a/esphome/components/hdc302x/sensor.py +++ b/esphome/components/hdc302x/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -16,6 +18,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -62,7 +67,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -86,7 +91,7 @@ HDC302X_HEATER_POWER_MAP = { } -def heater_power_value(value): +def heater_power_value(value: Any) -> cv.Lambda | int: """Accept enum names or raw uint16 values""" if isinstance(value, cv.Lambda): return value @@ -119,7 +124,12 @@ HDC302X_HEATER_ON_ACTION_SCHEMA = maybe_simple_id( HDC302X_HEATER_ON_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_on_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_POWER], args, cg.uint16) @@ -135,7 +145,12 @@ async def hdc302x_heater_on_to_code(config, action_id, template_arg, args): HDC302X_ACTION_SCHEMA, synchronous=True, ) -async def hdc302x_heater_off_to_code(config, action_id, template_arg, args): +async def hdc302x_heater_off_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/he60r/cover.py b/esphome/components/he60r/cover.py index a3a1b19f5a..4cb635b047 100644 --- a/esphome/components/he60r/cover.py +++ b/esphome/components/he60r/cover.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import cover, uart import esphome.config_validation as cv from esphome.const import CONF_CLOSE_DURATION, CONF_OPEN_DURATION +from esphome.types import ConfigType he60r_ns = cg.esphome_ns.namespace("he60r") HE60rCover = he60r_ns.class_("HE60rCover", cover.Cover, cg.Component) @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await cover.new_cover(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) 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/climate.py b/esphome/components/heatpumpir/climate.py index b7e0437480..2583839ca8 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -8,6 +8,7 @@ from esphome.const import ( CONF_VISUAL, ) from esphome.core import CORE +from esphome.types import ConfigType CODEOWNERS = ["@rob-deutsch"] @@ -97,6 +98,18 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } + +def _default_visual(config: ConfigType) -> ConfigType: + # Seed the visual min/max from the required min/max_temperature so the entity + # reports the configured range in Home Assistant instead of the ClimateIR + # 0-100 default. Done during validation so the effective range is visible in + # the dumped config and set before new_climate_ir() reads CONF_VISUAL. + visual = config.setdefault(CONF_VISUAL, {}) + visual.setdefault(CONF_MAX_TEMPERATURE, config[CONF_MAX_TEMPERATURE]) + visual.setdefault(CONF_MIN_TEMPERATURE, config[CONF_MIN_TEMPERATURE]) + return config + + CONFIG_SCHEMA = cv.All( climate_ir.climate_ir_with_receiver_schema(HeatpumpIRClimate).extend( { @@ -108,25 +121,18 @@ CONFIG_SCHEMA = cv.All( } ), cv.Any(cv.only_with_arduino, cv.only_on_esp32), + _default_visual, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) - if CONF_VISUAL not in config: - config[CONF_VISUAL] = {} - visual = config[CONF_VISUAL] - if CONF_MAX_TEMPERATURE not in visual: - visual[CONF_MAX_TEMPERATURE] = config[CONF_MAX_TEMPERATURE] - if CONF_MIN_TEMPERATURE not in visual: - visual[CONF_MIN_TEMPERATURE] = config[CONF_MIN_TEMPERATURE] cg.add(var.set_protocol(config[CONF_PROTOCOL])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE])) - cg.add_build_flag("-Wno-error=overloaded-virtual") - cg.add_library("tonia/HeatpumpIR", "1.0.41") + cg.add_library("tonia/HeatpumpIR", "1.0.42") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 8e9a2c5298..502f83cd5d 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -2,6 +2,7 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) +#include #include #include #include @@ -113,7 +114,7 @@ void HeatpumpIRClimate::setup() { this->current_temperature = state; IRSenderESPHome esp_sender(this->transmitter_); - this->heatpump_ir_->send(esp_sender, uint8_t(lround(this->current_temperature))); + this->heatpump_ir_->send(esp_sender, uint8_t(std::lround(this->current_temperature))); // current temperature changed, publish state this->publish_state(); 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/climate.py b/esphome/components/hitachi_ac344/climate.py index ebdf4e8db4..15da73b79c 100644 --- a/esphome/components/hitachi_ac344/climate.py +++ b/esphome/components/hitachi_ac344/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -9,5 +10,5 @@ HitachiClimate = hitachi_ac344_ns.class_("HitachiClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HitachiClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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/climate.py b/esphome/components/hitachi_ac424/climate.py index fde4e77545..d2a66223b1 100644 --- a/esphome/components/hitachi_ac424/climate.py +++ b/esphome/components/hitachi_ac424/climate.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import climate_ir +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -9,5 +10,5 @@ HitachiClimate = hitachi_ac424_ns.class_("HitachiClimate", climate_ir.ClimateIR) CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HitachiClimate) -async def to_code(config): +async def to_code(config: ConfigType) -> None: await climate_ir.new_climate_ir(config) 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.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 7a0dc0690c..964d26dfbc 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -242,7 +242,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) { return; } - if (data[1] != HlkFm22xResult::SUCCESS) { + if (data[1] != HlkFm22xResult::SUCCEEDED) { ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]); switch (expected) { case HlkFm22xCommand::ENROLL: diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h index fd8257b435..3bdf6e2c71 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.h +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -41,7 +41,7 @@ enum HlkFm22xNoteType { }; enum HlkFm22xResult { - SUCCESS = 0x00, + SUCCEEDED = 0x00, REJECTED = 0x01, ABORTED = 0x02, FAILED4_CAMERA = 0x04, @@ -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/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 1d793ac6b1..384477be3d 100644 --- a/esphome/components/hlw8012/sensor.py +++ b/esphome/components/hlw8012/sensor.py @@ -27,6 +27,7 @@ from esphome.const import ( UNIT_WATT_HOURS, ) from esphome.core import CORE +from esphome.types import ConfigType AUTO_LOAD = ["pulse_counter"] @@ -92,7 +93,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CORE.is_esp32: include_builtin_idf_component("esp_driver_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/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py index 846c9a398b..7b069d85d0 100644 --- a/esphome/components/hlw8032/sensor.py +++ b/esphome/components/hlw8032/sensor.py @@ -21,6 +21,7 @@ from esphome.const import ( UNIT_VOLT_AMPS, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -73,7 +74,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index f46a6b8580..02c6e75146 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -61,7 +61,7 @@ void HM3301Component::update() { int16_t aqi_value = -1; if (this->aqi_sensor_ != nullptr && pm_2_5_value != -1 && pm_10_0_value != -1) { aqi::AbstractAQICalculator *calculator = this->aqi_calculator_factory_.get_calculator(this->aqi_calc_type_); - aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value); + aqi_value = calculator->get_aqi(pm_2_5_value, pm_10_0_value, /*extended_range=*/false); } if (pm_1_0_value != -1) { 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/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 9546ae1c3c..2fa82b2710 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -17,6 +17,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_MICROGRAMS_PER_CUBIC_METER, ) +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -32,7 +33,7 @@ HM3301Component = hm3301_ns.class_( UNIT_INDEX = "index" -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if CONF_AQI in config and CONF_PM_2_5 not in config: raise cv.Invalid("AQI sensor requires PM 2.5") if CONF_AQI in config and CONF_PM_10_0 not in config: @@ -86,7 +87,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index cf3c594f36..a2e1f8054a 100644 --- a/esphome/components/hmc5883l/sensor.py +++ b/esphome/components/hmc5883l/sensor.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv @@ -17,6 +20,8 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -59,14 +64,16 @@ HMC5883L_RANGES = { } -def validate_enum(enum_values, units=None, int=True): +def validate_enum( + enum_values: dict[Any, Any], units: str | list[str] | None = None, int: bool = True +) -> Callable[[Any], Any]: _units = [] if units is not None: _units = units if isinstance(units, list) else [units] _units = [str(x) for x in _units] enum_bound = cv.enum(enum_values, int=int) - def validate_enum_bound(value): + def validate_enum_bound(value: Any) -> Any: value = cv.string(value) for unit in _units: if value.endswith(unit): @@ -112,7 +119,7 @@ CONFIG_SCHEMA = ( ) -def auto_data_rate(config): +def auto_data_rate(config: ConfigType) -> MockObj: interval_msec = config[CONF_UPDATE_INTERVAL].total_milliseconds interval_hz = 1000.0 / interval_msec for datarate in sorted(HMC5883LDatarates.keys()): @@ -121,7 +128,7 @@ def auto_data_rate(config): return HMC5883LDatarates[75] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@zweckj"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True + +CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" + +hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp") +HoermannHcp = hoermann_hcp_ns.class_( + "HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice +) + +# The Hoermann UAP module answers on Modbus server address 2. +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)}) + .extend(cv.polling_component_schema("500ms")) + .extend(modbus.modbus_device_schema(0x02, role="server")) +) + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device( + "hoermann_hcp", role="server" +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py new file mode 100644 index 0000000000..3de6a161e6 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_IS_CONNECTED = "is_connected" + +HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( + "HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema( + HoermannHcpConnectedBinarySensor, + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.COMPONENT_SCHEMA), + } + ), + cv.has_at_least_one_key(CONF_IS_CONNECTED), +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_IS_CONNECTED)) is not None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await binary_sensor.new_binary_sensor(conf, parent) + await cg.register_component(var, conf) diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp new file mode 100644 index 0000000000..edce6ce4c2 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -0,0 +1,18 @@ +#include "hoermann_hcp_binary_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.binary_sensor"; + +void HoermannHcpConnectedBinarySensor::setup() { + // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive + // their timers. + this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); }); + this->publish_initial_state(this->parent_->is_valid()); +} + +void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); } + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h new file mode 100644 index 0000000000..c111c17834 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + HoermannHcp *const parent_; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/button/__init__.py b/esphome/components/hoermann_hcp/button/__init__.py new file mode 100644 index 0000000000..dc2efcec44 --- /dev/null +++ b/esphome/components/hoermann_hcp/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ICON_AIR_FILTER +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_HALF_OPEN = "half_open" +CONF_VENT = "vent" + +ICON_GARAGE_OPEN_VARIANT = "mdi:garage-open-variant" + +HoermannHcpVentButton = hoermann_hcp_ns.class_("HoermannHcpVentButton", button.Button) +HoermannHcpHalfOpenButton = hoermann_hcp_ns.class_( + "HoermannHcpHalfOpenButton", button.Button +) + +BUTTON_KEYS = (CONF_VENT, CONF_HALF_OPEN) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_VENT): button.button_schema( + HoermannHcpVentButton, icon=ICON_AIR_FILTER + ), + cv.Optional(CONF_HALF_OPEN): button.button_schema( + HoermannHcpHalfOpenButton, icon=ICON_GARAGE_OPEN_VARIANT + ), + } + ), + cv.has_at_least_one_key(*BUTTON_KEYS), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + for key in BUTTON_KEYS: + if (conf := config.get(key)) is not None: + await button.new_button(conf, parent) diff --git a/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h b/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h new file mode 100644 index 0000000000..e9ebceee88 --- /dev/null +++ b/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +// The door commands the cover has no equivalent for. A refused command is already reported by the hub and +// leaves nothing to correct here, because a button carries no state of its own. +class HoermannHcpButton : public button::Button { + public: + explicit HoermannHcpButton(HoermannHcp *parent) : parent_(parent) {} + + protected: + HoermannHcp *const parent_; +}; + +class HoermannHcpVentButton final : public HoermannHcpButton { + public: + using HoermannHcpButton::HoermannHcpButton; + + protected: + void press_action() override { this->parent_->vent_door(); } +}; + +class HoermannHcpHalfOpenButton final : public HoermannHcpButton { + public: + using HoermannHcpButton::HoermannHcpButton; + + protected: + void press_action() override { this->parent_->half_open_door(); } +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component) + +CONFIG_SCHEMA = ( + cover.cover_schema(HoermannHcpCover) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await cover.new_cover(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -0,0 +1,87 @@ +#include "hoermann_hcp_cover.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.cover"; + +cover::CoverTraits HoermannHcpCover::get_traits() { + cover::CoverTraits traits; + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void HoermannHcpCover::setup() { + // Nothing is published before the bus controller is heard from, and the untouched position reads as fully + // open, so flag the entity until the first contact clears it again. + this->status_set_warning("waiting for the bus controller"); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); } + +void HoermannHcpCover::control(const cover::CoverCall &call) { + bool accepted = true; + if (call.get_stop()) + accepted &= this->parent_->stop_door(); + if (call.get_toggle().has_value()) + accepted &= this->parent_->impulse_door(); + if (const auto position = call.get_position()) + accepted &= this->parent_->set_position(*position); + if (!accepted) { + // The command never reached the door, so publish the unchanged state over the one the caller assumed. + ESP_LOGW(TAG, "Command was not accepted by the door"); + this->publish_state(false); + } +} + +void HoermannHcpCover::update_from_state_() { + if (!this->parent_->is_valid()) { + this->status_set_warning(); + // The door can now move unheard, so drop the baseline a direction would be inferred from and stop + // reporting motion instead of leaving the cover travelling until the controller returns. + this->previous_position_ = NAN; + if (this->current_operation != cover::COVER_OPERATION_IDLE) { + this->current_operation = cover::COVER_OPERATION_IDLE; + this->publish_state(); + } + return; + } + this->status_clear_warning(); + + const auto previous_operation = this->current_operation; + const float current_position = this->parent_->get_current_position(); + switch (this->parent_->get_door_state()) { + case DoorState::OPENING: + this->current_operation = cover::COVER_OPERATION_OPENING; + break; + case DoorState::CLOSING: + this->current_operation = cover::COVER_OPERATION_CLOSING; + break; + case DoorState::MOVE_VENTING: + case DoorState::MOVE_HALF: + // These states carry no direction, so keep the current one until the position actually moves. + if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) { + this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING + : cover::COVER_OPERATION_CLOSING; + } + break; + default: + this->current_operation = cover::COVER_OPERATION_IDLE; + break; + } + this->previous_position_ = current_position; + + // Compare against the position last published, which starts at COVER_OPEN rather than at zero. + const bool changed = this->position != current_position || previous_operation != this->current_operation; + this->position = current_position; + if (changed) { + // The bus reports the position on every broadcast, so nothing here is worth restoring from flash. + this->publish_state(false); + } +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpCover : public cover::Cover, public Component { + public: + explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + cover::CoverTraits get_traits() override; + void control(const cover::CoverCall &call) override; + + protected: + void update_from_state_(); + HoermannHcp *const parent_; + // NAN until the first position is observed, so no direction is inferred from a baseline that never existed. + float previous_position_{NAN}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..17df927eb7 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,469 @@ +#include "hoermann_hcp.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp"; + +// Hoermann HCP holding-register blocks. +static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller +static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller +static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller +static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; +static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; + +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The intermediate positions are named in the second register, so the first only carries the phase. +static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000}; +static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; + +// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because +// its low byte tells a plain stop from the vent position. +struct DoorStateMapping { + uint8_t code; + DoorState state; +}; +static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = { + {0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF}, + {0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN}, + {0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN}, +}; + +// The hub rejects a reply whose register count does not match the request, so an unrecognized block length +// is padded with zeros rather than answered with an exception that would fail the controller's whole poll. +static void push_zeros(modbus::RegisterValues ®isters, uint16_t count) { + for (uint16_t i = 0; i < count; i++) + registers.push_back(0x0000); +} + +// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving. +static bool is_moving(DoorState state) { + switch (state) { + case DoorState::OPENING: + case DoorState::CLOSING: + case DoorState::MOVE_HALF: + case DoorState::MOVE_VENTING: + return true; + default: + return false; + } +} + +void HoermannHcp::update() { + const uint32_t now = millis(); + // Time out the connection flag if the bus controller stopped polling. + if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_) + this->set_valid_(false); + // Status broadcasts alone keep the connection alive, so a command the controller never fetches would + // otherwise block every later one for as long as it keeps broadcasting. + if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; + } + // A target waits for a door still travelling the other way to turn around. If it never does, the target has + // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); + this->clear_target_(); + } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } + if (this->changed_) { + this->changed_ = false; + this->state_callback_.call(); + } +} + +void HoermannHcp::dump_config() { + ESP_LOGCONFIG(TAG, + "Hoermann HCP bridge:\n" + " Modbus server address: 0x%02X", + this->get_address()); +} + +modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + if (start_address != STATE_REG) { + ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message + // counter (high byte) and command (low byte). The read length identifies which internal block is requested. + const uint16_t counter = this->command_reg_value_ & 0xFF00; + const uint16_t command = static_cast((this->command_reg_value_ & 0x00FF) << 8); + + switch (number_of_registers) { + case 8: + // Command request: return the internal state, injecting any pending command. + registers.push_back(counter); + registers.push_back(static_cast(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(0x0004 | counter)); + registers.push_back(command); + break; + case 5: + // Bus scan (the bus controller discovering us, typically at startup). + ESP_LOGD(TAG, "Bus scan received from bus controller"); + registers.push_back(counter); + registers.push_back(static_cast(0x0005 | command)); + registers.push_back(0x0430); + registers.push_back(0x10FF); + registers.push_back(0xA845); + break; + default: + ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers); + push_zeros(registers, number_of_registers); + break; + } + + return {}; +} + +modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + if (start_address == COMMAND_REG) { + // 0x17 write half: stash the command register so the following read half can echo its message counter and + // command byte back from STATE_REG. The hub always runs the write before the read within one request. + this->record_response_(); + this->command_reg_value_ = registers[0]; + return {}; + } + + if (start_address != BROADCAST_REG) { + // Every device sees every broadcast, so a frame meant for another node is ordinary traffic + ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new + // position checks the target against the new state. + if (registers.size() > 2) + this->on_state_reg_(registers[2]); + if (registers.size() > 1) + this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } + return {}; +} + +void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) { + push_zeros(registers, 2); + return; + } + if (this->command_written_at_ == 0) { + // First read after the command was queued: present the "key pressed" values. + this->command_written_at_ = millis(); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->pressed_value); + registers.push_back(command->pressed_value_2); + return; + } + if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { + // Between the two events there is nothing to report, including in the second register. + push_zeros(registers, 2); + return; + } + // Enough time passed: present the "key released" values and clear the command. + ESP_LOGD(TAG, "Released '%s' command", command->name); + this->command_written_at_ = 0; + this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); + registers.push_back(command->released_value); + registers.push_back(command->released_value_2); +} + +void HoermannHcp::on_position_reg_(uint16_t value) { + // Low byte: current position. + const uint8_t position = static_cast(value); + if (this->position_raw_ == position) + return; + + this->position_raw_ = position; + this->update_current_position_(); + // Until the door actually travels the way it was told to, its position says nothing about the target. + if (!this->has_target_() || !this->target_started_) + return; + + // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way. + const bool reached = this->target_direction_ == DoorState::OPENING + ? this->current_position_ >= this->target_position_ + : this->current_position_ <= this->target_position_; + if (reached) + this->stop_door(); +} + +void HoermannHcp::on_state_reg_(uint16_t value) { + // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte. + const uint16_t previous = this->prev_state_reg_; + this->prev_state_reg_ = value; + if (previous == value) + return; + + const uint8_t state = value >> 8; + if (state == 0x00) { + // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop. + this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED); + return; + } + for (const auto &mapping : DOOR_STATE_MAPPINGS) { + if (mapping.code == state) { + this->set_door_state_(mapping.state); + return; + } + } + // The low byte can change on its own, so only report a state we cannot decode once. + if (state != (previous >> 8)) + ESP_LOGW(TAG, "Unknown door state 0x%02X", state); +} + +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + +bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { + if (!this->valid_) { + // Queueing now would fire the command whenever the controller comes back, which may be much later. + ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); + return false; + } + if (this->next_command_ != nullptr) { + ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); + return false; + } + // A new command supersedes any half-open target the door was still travelling to. + if (command.clears_target) + this->clear_target_(); + this->next_command_ = &command; + this->command_queued_at_ = millis(); + return true; +} + +bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); } +bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} + +bool HoermannHcp::stop_door() { + if (!is_moving(this->door_state_)) { + this->clear_target_(); + return true; + } + // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. + return this->queue_command_(COMMAND_IMPULSE); +} + +bool HoermannHcp::set_position(float position) { + // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed. + if (position <= CLOSE_POSITION_THRESHOLD) + return this->close_door(); + if (position >= OPEN_POSITION_THRESHOLD) + return this->open_door(); + // Asking the door to travel to where it already is means stopping it. + if (position == this->current_position_) + return this->stop_door(); + + // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. + const bool opening = position > this->current_position_; + if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) + return false; + this->target_position_ = position; + this->target_queued_at_ = millis(); + this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; + // A door already travelling that way is on its way; one moving the other way has to turn around first. + this->target_started_ = this->door_state_ == this->target_direction_; + return true; +} + +void HoermannHcp::record_response_() { + this->last_response_ = millis(); + this->set_valid_(true); +} + +void HoermannHcp::set_valid_(bool valid) { + if (this->valid_ == valid) + return; + this->valid_ = valid; + this->changed_ = true; + if (valid) { + ESP_LOGI(TAG, "Bus controller connected"); + return; + } + ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); + // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. + this->next_command_ = nullptr; + this->command_written_at_ = 0; + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; +} + +void HoermannHcp::set_door_state_(DoorState state) { + if (this->door_state_ == state) + return; + this->door_state_ = state; + this->changed_ = true; + this->update_current_position_(); + if (!this->has_target_()) + return; + if (state == this->target_direction_) { + this->target_started_ = true; + } else if (this->target_started_ && !is_moving(state)) { + // The door came to rest without reaching the target, so the request it belonged to is over. + this->clear_target_(); + } +} + +void HoermannHcp::update_current_position_() { + // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so + // trust the reported end stop over the raw count. + float position = static_cast(this->position_raw_) / 200.0f; + if (this->door_state_ == DoorState::CLOSED) { + position = 0.0f; + } else if (this->door_state_ == DoorState::OPEN) { + position = 1.0f; + } + if (this->current_position_ != position) { + this->current_position_ = position; + this->changed_ = true; + } +} + +void HoermannHcp::clear_target_() { + this->target_position_ = 0.0f; + this->target_started_ = false; +} + +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..83be385c7b --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,151 @@ +#pragma once + +#include + +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::hoermann_hcp { + +// Door state as reported by the Hoermann bus controller. +enum class DoorState : uint8_t { + OPEN, + OPENING, + CLOSED, + CLOSING, + HALF_OPEN, + MOVE_VENTING, + VENT, + MOVE_HALF, + STOPPED, +}; + +// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a +// short delay the released value. Each half also carries a second register, which names the buttons that do +// not fit into the first. +struct HoermannHcpCommand { + const char *name; + uint16_t pressed_value; + uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; +}; + +class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { + public: + void update() override; + void dump_config() override; + + // Registered by child entities to be notified when the door state changes. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + + // Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write + // half first, storing the command register that the read half echoes back) and broadcasts status with 0x10. + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) override; + modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) override; + + // Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus + // controller cannot be asked right now, so the caller can react. + bool open_door(); + bool close_door(); + bool impulse_door(); + // The door drives to these intermediate positions on its own, so neither takes a target to be stopped at. + bool vent_door(); + bool half_open_door(); + bool stop_door(); + bool set_position(float position); + bool toggle_light(); + + DoorState get_door_state() const { return this->door_state_; } + float get_current_position() const { return this->current_position_; } + bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); + + protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; + void record_response_(); + // Returns false when the bus controller has not fetched the previous command yet. + bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); + // Appends the two key-press registers and advances the pending command's press/release state. + void push_command_registers_(modbus::RegisterValues ®isters); + void on_position_reg_(uint16_t value); + void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); + + void set_valid_(bool valid); + void set_door_state_(DoorState state); + // Recomputes the reported position from position_raw_ and the current door state. + void update_current_position_(); + bool has_target_() const { return this->target_position_ != 0.0f; } + void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); + + CallbackManager state_callback_; + + float current_position_{0.0f}; + // Position the door was told to travel to; 0.0 means no target is armed. + float target_position_{0.0f}; + + // Pending command / key-press state machine. + const HoermannHcpCommand *next_command_{nullptr}; + uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; + uint32_t command_written_at_{0}; + uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; + + // A command is "pressed" for this long before its end value is sent. + uint16_t key_press_delay_ms_{100}; + // Drop the "connected" flag if the bus controller has not polled us for this long. + uint16_t connection_timeout_ms_{2000}; + // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when + // it reads 0x0000. + uint16_t prev_state_reg_{0xFFFF}; + // 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message + // counter and low-byte command back from STATE_REG. + uint16_t command_reg_value_{0}; + + DoorState door_state_{DoorState::CLOSED}; + // Direction the door was started in for the current target. A target armed while the door is still travelling + // the other way must not be judged by the reported direction until the door has turned around. + DoorState target_direction_{DoorState::STOPPED}; + // Position as reported by the bus controller, 0..200 across the full travel. + uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; + bool target_started_{false}; + bool valid_{false}; + bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/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.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 7b23775b47..1b66842f1e 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -1,13 +1,19 @@ +from collections.abc import Callable, Iterable + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] homeassistant_ns = cg.esphome_ns.namespace("homeassistant") -def validate_entity_domain(platform, supported_domains): - def validator(config): +def validate_entity_domain( + platform: str, supported_domains: Iterable[str] +) -> Callable[[ConfigType], ConfigType]: + def validator(config: ConfigType) -> ConfigType: domain = config[CONF_ENTITY_ID].split(".", 1)[0] if domain not in supported_domains: raise cv.Invalid( @@ -34,7 +40,7 @@ HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( ) -def setup_home_assistant_entity(var, config): +def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index a943368dd7..6ea17b6831 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) 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/__init__.py b/esphome/components/homeassistant/number/__init__.py index 8f760772c3..ab1389e13a 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -22,7 +23,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = await number.new_number( config, 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/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index 6437476827..abee957fda 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 112795a4ff..b79a56953a 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -17,9 +17,9 @@ void HomeassistantSensor::setup() { } if (this->attribute_ != nullptr) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); + ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); + ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); 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/__init__.py b/esphome/components/homeassistant/switch/__init__.py index c299a731f2..55854cd659 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, @@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index b59f9d23df..265250c695 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, @@ -18,7 +19,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) setup_home_assistant_entity(var, config) 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/homeassistant/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 62cb96a25a..146b8278ea 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/time/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_TIMEZONE +from esphome.types import ConfigType from .. import homeassistant_ns @@ -16,8 +17,10 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await time_.register_time(var, config) await cg.register_component(var, config) cg.add_define("USE_HOMEASSISTANT_TIME") + if CONF_TIMEZONE not in config: + cg.add_define("USE_HOMEASSISTANT_TIMEZONE") 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/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 93ae2b6056..5250e1c1c7 100644 --- a/esphome/components/honeywell_hih_i2c/sensor.py +++ b/esphome/components/honeywell_hih_i2c/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index 25d03d31a6..4b116f0f16 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType DEPENDENCIES = ["spi"] CODEOWNERS = ["@RubyBailey"] @@ -50,7 +51,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 2708e5d423..299acd4b52 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -57,7 +58,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 8adbfb02ec..bd074ab6b5 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -10,18 +10,20 @@ from esphome.const import ( ThreadModel, ) from esphome.core import CORE +from esphome.platformio.toolchain import copy_ccache_script +from esphome.types import ConfigType from .const import KEY_HOST # force import gpio to register pin schema -from .gpio import host_pin_to_code # noqa +from .gpio import host_pin_to_code # noqa: F401 CODEOWNERS = ["@esphome/core", "@clydebarrow"] AUTO_LOAD = ["network", "preferences"] IS_TARGET_PLATFORM = True -def set_core_data(config): +def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_HOST] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_HOST CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "host" @@ -35,17 +37,27 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, } ), + cv.require_platformio_toolchain("host"), set_core_data, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DUSE_HOST") cg.add_define("USE_NATIVE_64BIT_TIME") + # The prefs file finds stored preferences by key, so key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts) cg.add_build_flag("-std=gnu++20") cg.add_define("ESPHOME_BOARD", "host") + cg.add_define("ESPHOME_VARIANT", "HOST") cg.add_define(ThreadModel.MULTI_ATOMICS) cg.add_platformio_option("platform", "platformio/native") cg.add_platformio_option("lib_ldf_mode", "off") cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) + + +# Called by writer.py +def copy_files() -> None: + copy_ccache_script() 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/gpio.py b/esphome/components/host/gpio.py index fcfb0b6c54..e39d35d077 100644 --- a/esphome/components/host/gpio.py +++ b/esphome/components/host/gpio.py @@ -1,4 +1,5 @@ import logging +from typing import Any from esphome import pins import esphome.codegen as cg @@ -14,6 +15,8 @@ from esphome.const import ( CONF_PULLDOWN, CONF_PULLUP, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from .const import host_ns @@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) HostGPIOPin = host_ns.class_("HostGPIOPin", cg.InternalGPIOPin) -def _translate_pin(value): +def _translate_pin(value: Any) -> int | str: if isinstance(value, dict) or value is None: raise cv.Invalid( "This variable only supports pin numbers, not full pin schemas " @@ -41,7 +44,7 @@ def _translate_pin(value): return value -def validate_gpio_pin(value): +def validate_gpio_pin(value: Any) -> int | str: return _translate_pin(value) @@ -53,7 +56,7 @@ HOST_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register("host", HOST_PIN_SCHEMA) -async def host_pin_to_code(config): +async def host_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(num)) diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7e8849b3e1..7274d9de57 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS; + static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); } 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..b591fa0aab 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -23,16 +23,19 @@ 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; } + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len) { return this->load(type, data, len); } + bool load(uint32_t key, uint8_t *data, size_t len) { 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 +48,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/__init__.py b/esphome/components/host/time/__init__.py index d9a2f1207c..6eb0cf954d 100644 --- a/esphome/components/host/time/__init__.py +++ b/esphome/components/host/time/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import time as time_ import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] @@ -14,7 +15,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await time_.register_time(var, config) 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/hrxl_maxsonar_wr/sensor.py b/esphome/components/hrxl_maxsonar_wr/sensor.py index d335d76dfa..e4daacd869 100644 --- a/esphome/components/hrxl_maxsonar_wr/sensor.py +++ b/esphome/components/hrxl_maxsonar_wr/sensor.py @@ -5,6 +5,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType CODEOWNERS = ["@netmikey"] DEPENDENCIES = ["uart"] @@ -23,7 +24,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ).extend(uart.UART_DEVICE_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) 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/hte501/sensor.py b/esphome/components/hte501/sensor.py index 17ae3a3d1b..bf9fe4000e 100644 --- a/esphome/components/hte501/sensor.py +++ b/esphome/components/hte501/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@Stock-M"] @@ -44,7 +45,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 90879c459e..de35d52a40 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,3 +1,6 @@ +from pathlib import Path +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import esp32 @@ -14,12 +17,16 @@ from esphome.const import ( CONF_TIMEOUT, CONF_URL, CONF_WATCHDOG_TIMEOUT, + PLATFORM_ESP32, PLATFORM_HOST, PlatformFramework, __version__, ) -from esphome.core import CORE, Lambda +from esphome.core import CORE, ID, Lambda, TimePeriodMilliseconds +from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.helpers import IS_MACOS +from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] @@ -61,17 +68,17 @@ CONF_BODY = "body" CONF_JSON = "json" -def validate_url(value): +def validate_url(value: Any) -> str: value = cv.url(value) - if value.startswith("http://") or value.startswith("https://"): + if value.startswith(("http://", "https://")): return value raise cv.Invalid("URL must start with 'http://' or 'https://'") -def validate_ssl_verification(config): +def validate_ssl_verification(config: ConfigType) -> ConfigType: 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 ( @@ -89,12 +96,40 @@ def validate_ssl_verification(config): return config -def _declare_request_class(value): +# esp_http_client_open() runs DNS, TCP connect and the TLS handshake with no +# watchdog feed in between; each can take up to `timeout` on ESP-IDF. +WATCHDOG_TIMEOUT_MULTIPLIER = 3 +# Headroom over the exact worst case so a fully stalled open does not land on +# the watchdog deadline. +WATCHDOG_TIMEOUT_MARGIN_MS = 1000 + + +def default_watchdog_timeout(config: ConfigType) -> None: + """Arm the request watchdog on ESP32 when the user did not set it. + + The default never goes below the platform task watchdog, so a user who + widened `esp32.watchdog_timeout` keeps that window during requests. + """ + if not CORE.is_esp32 or CONF_WATCHDOG_TIMEOUT in config: + return + derived_ms = ( + config[CONF_TIMEOUT].total_milliseconds * WATCHDOG_TIMEOUT_MULTIPLIER + + WATCHDOG_TIMEOUT_MARGIN_MS + ) + platform_ms = fv.full_config.get()[PLATFORM_ESP32][ + CONF_WATCHDOG_TIMEOUT + ].total_milliseconds + config[CONF_WATCHDOG_TIMEOUT] = TimePeriodMilliseconds( + milliseconds=max(derived_ms, platform_ms) + ) + + +def _declare_request_class(value: Any) -> ID: if CORE.is_host: 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 @@ -116,7 +151,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, ), @@ -142,14 +177,16 @@ 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, ) +FINAL_VALIDATE_SCHEMA = default_watchdog_timeout -async def to_code(config): + +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_useragent(config[CONF_USERAGENT])) @@ -165,8 +202,11 @@ async def to_code(config): cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) + # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). + # esp-tls is re-enabled too because http_request includes + # directly and esp_http_client only pulls it in as a private dependency. esp32.include_builtin_idf_component("esp_http_client") + esp32.include_builtin_idf_component("esp-tls") cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) @@ -174,7 +214,7 @@ async def to_code(config): if config.get(CONF_VERIFY_SSL): if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): - with open(ca_cert_path, encoding="utf-8") as f: + with Path(ca_cert_path).open(encoding="utf-8") as f: ca_cert_content = f.read() cg.add(var.set_ca_certificate(ca_cert_content)) else: @@ -188,9 +228,7 @@ async def to_code(config): # framework: # advanced: # use_full_certificate_bundle: true - esp32.add_idf_sdkconfig_option( - "CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True - ) + esp32.require_certificate_bundle() esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_INSECURE", @@ -202,7 +240,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: @@ -296,7 +334,12 @@ HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend( HTTP_REQUEST_SEND_ACTION_SCHEMA, synchronous=True, ) -async def http_request_action_to_code(config, action_id, template_arg, args): +async def http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -366,7 +409,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..4471dffdc2 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) @@ -488,10 +488,10 @@ template class HttpRequestSendAction : public Action { body = this->body_.value(x...); } if (!this->json_.empty()) { - body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); }); + body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); }); } if (this->json_func_ != nullptr) { - body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); }); + body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); }); } std::vector
request_headers; request_headers.reserve(this->request_headers_.size()); @@ -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..43ab2e5b53 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -1,6 +1,6 @@ #include "http_request_arduino.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -16,7 +16,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.arduino"; +static const char *const TAG = "http_request"; #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; @@ -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..028b9f44a1 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -2,9 +2,9 @@ #include "http_request.h" -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) -#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.cpp b/esphome/components/http_request/http_request_host.cpp index 85c6e8b3c7..cf231e20bd 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -14,7 +14,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.host"; +static const char *const TAG = "http_request"; std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, const std::string &body, 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.cpp b/esphome/components/http_request/http_request_idf.cpp index 3e341395a4..10313be89d 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -16,14 +16,9 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.idf"; +static const char *const TAG = "http_request"; static constexpr uint32_t ERROR_DURATION_MS = 1000; -struct UserData { - const std::vector &lower_case_collect_headers; - std::vector
&response_headers; -}; - void HttpRequestIDF::dump_config() { HttpRequestComponent::dump_config(); ESP_LOGCONFIG(TAG, @@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() { } esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { - UserData *user_data = (UserData *) evt->user_data; + auto *container = (HttpContainerIDF *) evt->user_data; switch (evt->event_id) { case HTTP_EVENT_ON_HEADER: { const std::string header_name = str_lower_case(evt->header_key); // NOLINT - if (should_collect_header(user_data->lower_case_collect_headers, header_name)) { + if (should_collect_header(container->collect_headers_, header_name)) { const std::string header_value = evt->header_value; ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str()); - user_data->response_headers.push_back({header_name, header_value}); + container->response_headers_.push_back({header_name, header_value}); } break; } @@ -124,8 +119,8 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c container->set_secure(secure); - auto user_data = UserData{lower_case_collect_headers, container->response_headers_}; - esp_http_client_set_user_data(client, static_cast(&user_data)); + container->collect_headers_ = lower_case_collect_headers; + esp_http_client_set_user_data(client, static_cast(container.get())); for (const auto &header : request_headers) { esp_http_client_set_header(client, header.name.c_str(), header.value.c_str()); @@ -147,12 +142,13 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c const char *buf = body.c_str(); while (write_left > 0) { int written = esp_http_client_write(client, buf + write_index, write_left); - if (written < 0) { + if (written <= 0) { err = ESP_FAIL; break; } write_left -= written; write_index += written; + container->feed_wdt(); } } @@ -201,6 +197,9 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } container->feed_wdt(); + // IDF is the only backend reusing the container across redirect hops; + // drop the previous hop's headers (Arduino/host collect only the final response) + container->response_headers_.clear(); container->content_length = esp_http_client_fetch_headers(client); container->set_chunked(esp_http_client_is_chunked_response(client)); container->feed_wdt(); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 9ed1a97b1a..16a5b6a161 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -24,9 +24,11 @@ class HttpContainerIDF : public HttpContainer { protected: friend class HttpRequestIDF; esp_http_client_handle_t client_; + // Owned copy (not a reference): must outlive perform() for the response-header event handler + std::vector collect_headers_; }; -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 fb59e51943..784e4ee47a 100644 --- a/esphome/components/http_request/ota/__init__.py +++ b/esphome/components/http_request/ota/__init__.py @@ -3,8 +3,10 @@ import esphome.codegen as cg from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_URL, CONF_USERNAME -from esphome.core import coroutine_with_priority +from esphome.core import ID, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns @@ -36,13 +38,13 @@ 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), ), ) @coroutine_with_priority(CoroPriority.OTA_UPDATES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ota_to_code(var, config) await cg.register_component(var, config) @@ -57,7 +59,7 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( cv.Optional(CONF_MD5): cv.templatable( cv.All(cv.string, cv.Length(min=32, max=32)) ), - cv.Optional(CONF_PASSWORD): cv.templatable(cv.string), + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.templatable(cv.string)), cv.Optional(CONF_USERNAME): cv.templatable(cv.string), cv.Required(CONF_URL): cv.templatable(cv.url), } @@ -72,7 +74,12 @@ OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA = cv.All( OTA_HTTP_REQUEST_FLASH_ACTION_SCHEMA, synchronous=True, ) -async def ota_http_request_action_to_code(config, action_id, template_arg, args): +async def ota_http_request_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) 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/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8893b96c65..7e7594c3c3 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() { } } -void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container) { - if (this->update_started_) { +void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, + bool abort_backend) { + if (abort_backend) { ESP_LOGV(TAG, "Aborting OTA backend"); backend->abort(); } @@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() { auto error_code = backend->begin(container->content_length); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "backend->begin error: %d", error_code); - this->cleanup_(std::move(backend), container); + // Nothing to abort: begin() failed, so no OTA handle was opened + this->cleanup_(std::move(backend), container, /*abort_backend=*/false); return error_code; } @@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { } else { ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error); } - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return OTA_CONNECTION_ERROR; } @@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() { md5_receive.add(buf, bufsize_or_error); // write bytes to OTA backend - this->update_started_ = true; error_code = backend->write(buf, bufsize_or_error); if (error_code != ota::OTA_RESPONSE_OK) { // error code explanation available at // https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code, container->get_bytes_read() - bufsize_or_error, container->content_length); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } } @@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { this->md5_computed_ = md5_receive_str; if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) { ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str()); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH; } else { backend->set_update_md5(md5_receive_str); @@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() { error_code = backend->end(); if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code); - this->cleanup_(std::move(backend), container); + this->cleanup_(std::move(backend), container, /*abort_backend=*/true); return error_code; } diff --git a/esphome/components/http_request/ota/ota_http_request.h b/esphome/components/http_request/ota/ota_http_request.h index a706331d9a..9bb748f175 100644 --- a/esphome/components/http_request/ota/ota_http_request.h +++ b/esphome/components/http_request/ota/ota_http_request.h @@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< void flash(); protected: - void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container); + void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr &container, bool abort_backend); uint8_t do_ota_(); std::string get_url_with_auth_(const std::string &url); bool http_get_md5_(); @@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented< std::string username_{}; std::string url_{}; int status_ = -1; - bool update_started_ = false; static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size }; diff --git a/esphome/components/http_request/update/__init__.py b/esphome/components/http_request/update/__init__.py index d84d80109a..4bdc30e4cf 100644 --- a/esphome/components/http_request/update/__init__.py +++ b/esphome/components/http_request/update/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ota, update import esphome.config_validation as cv from esphome.const import CONF_SOURCE +from esphome.types import ConfigType from .. import CONF_HTTP_REQUEST_ID, HttpRequestComponent, http_request_ns from ..ota import OtaHttpRequestComponent @@ -29,7 +30,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await update.new_update(config) ota_parent = await cg.get_variable(config[CONF_OTA_ID]) cg.add(var.set_ota_parent(ota_parent)) 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/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 8808dc70f5..86dca77725 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -17,6 +17,9 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -63,7 +66,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -95,7 +98,12 @@ async def to_code(config): ), synchronous=True, ) -async def set_heater_level_to_code(config, action_id, template_arg, args): +async def set_heater_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) level_ = await cg.templatable(config[CONF_LEVEL], args, cg.uint8) @@ -115,7 +123,12 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def set_heater_to_code(config, action_id, template_arg, args): +async def set_heater_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) status_ = await cg.templatable(config[CONF_STATUS], args, cg.bool_) 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/htu31d/sensor.py b/esphome/components/htu31d/sensor.py index 638a8d77c5..8960759d9b 100644 --- a/esphome/components/htu31d/sensor.py +++ b/esphome/components/htu31d/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/hub75/boards/__init__.py b/esphome/components/hub75/boards/__init__.py index 52f8864c60..818ee732a3 100644 --- a/esphome/components/hub75/boards/__init__.py +++ b/esphome/components/hub75/boards/__init__.py @@ -49,7 +49,7 @@ class BoardConfig: # Derived field for pin lookup pins: dict[str, int | None] = field(default_factory=dict, init=False, repr=False) - def __post_init__(self): + def __post_init__(self) -> None: """Initialize derived fields and register board.""" self.name = self.name.lower() self.pins = { diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 0d1b87941d..3522acf049 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.""" +def _validate_scan_wiring(value: Any) -> str: + """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( @@ -337,7 +315,7 @@ def _validate_config(config: ConfigType) -> ConfigType: return config -def _final_validate(config: ConfigType) -> ConfigType: +def _final_validate(config: ConfigType) -> None: """Validate requirements when using HUB75 display.""" # Local imports to avoid circular dependencies from esphome.components.esp32 import get_esp32_variant @@ -403,8 +381,6 @@ def _final_validate(config: ConfigType) -> ConfigType: if errs: raise cv.MultipleInvalid(errs) - return config - FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) @@ -501,7 +477,7 @@ def _build_pins_struct( ) -> cg.StructInitializer: """Build Hub75Pins struct from pin expressions.""" - def pin_cast(pin): + def pin_cast(pin: Any) -> cg.RawExpression: return cg.RawExpression(f"static_cast({pin.get_pin()})") return cg.StructInitializer( 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/hx711/sensor.py b/esphome/components/hx711/sensor.py index a5d11e9241..2739589c66 100644 --- a/esphome/components/hx711/sensor.py +++ b/esphome/components/hx711/sensor.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv from esphome.const import CONF_CLK_PIN, CONF_GAIN, ICON_SCALE, STATE_CLASS_MEASUREMENT +from esphome.types import ConfigType hx711_ns = cg.esphome_ns.namespace("hx711") HX711Sensor = hx711_ns.class_("HX711Sensor", sensor.Sensor, cg.PollingComponent) @@ -34,7 +35,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/hydreon_rgxx/binary_sensor.py b/esphome/components/hydreon_rgxx/binary_sensor.py index f899ce71ce..193db9b20d 100644 --- a/esphome/components/hydreon_rgxx/binary_sensor.py +++ b/esphome/components/hydreon_rgxx/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, DEVICE_CLASS_COLD, DEVICE_CLASS_PROBLEM +from esphome.types import ConfigType from . import HydreonRGxxComponent, hydreon_rgxx_ns @@ -32,7 +33,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: main_sensor = await cg.get_variable(config[CONF_HYDREON_RGXX_ID]) bin_component = cg.new_Pvariable(config[CONF_ID], main_sensor) await cg.register_component(bin_component, config) 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/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index fdb606182f..58e72571ff 100644 --- a/esphome/components/hydreon_rgxx/sensor.py +++ b/esphome/components/hydreon_rgxx/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import HydreonRGxxComponent, RG15Resolution, RGModel @@ -65,7 +66,7 @@ PROTOCOL_NAMES = { } -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: for conf, models in SUPPORTED_OPTIONS.items(): if conf in config and config[CONF_MODEL] not in models: raise cv.Invalid( @@ -130,7 +131,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/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/hyt271/sensor.py b/esphome/components/hyt271/sensor.py index bf37646d4f..3f006a65fe 100644 --- a/esphome/components/hyt271/sensor.py +++ b/esphome/components/hyt271/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 1684f479ba..b053125446 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,4 +1,7 @@ import logging +import re +import sys +from typing import Any from esphome import pins import esphome.codegen as cg @@ -11,14 +14,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, @@ -29,6 +36,7 @@ from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_DEVICE, CONF_FREQUENCY, CONF_I2C, CONF_I2C_ID, @@ -40,13 +48,15 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_HOST, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.cpp_generator import MockObj import esphome.final_validate as fv +from esphome.types import ConfigType LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] @@ -56,6 +66,7 @@ InternalI2CBus = i2c_ns.class_("InternalI2CBus", I2CBus) ArduinoI2CBus = i2c_ns.class_("ArduinoI2CBus", InternalI2CBus, cg.Component) IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) +HostI2CBus = i2c_ns.class_("HostI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") ESP32_I2C_CAPABILITIES = { @@ -67,14 +78,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()) @@ -83,17 +98,25 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True -def _bus_declare_type(value): +def validate_device(value: str) -> str: + if not re.match(r"^/(?:[^/]+/)*[^/]+$", value): + raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)") + return value + + +def _bus_declare_type(value: Any) -> ID: if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: return cv.declare_id(ArduinoI2CBus)(value) if CORE.using_zephyr: return cv.declare_id(ZephyrI2CBus)(value) + if CORE.is_host: + return cv.declare_id(HostI2CBus)(value) raise NotImplementedError -def _rp2040_i2c_controller(pin): +def _rp2040_i2c_controller(pin: int) -> int: """Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin. See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"): @@ -104,12 +127,12 @@ def _rp2040_i2c_controller(pin): return (pin // 2) % 2 -def validate_config(config): +def validate_config(config: ConfigType) -> ConfigType: if CORE.is_esp32: 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: @@ -121,15 +144,48 @@ def validate_config(config): return config +def validate_host_config(config: ConfigType) -> ConfigType: + if CORE.is_host: + # Host I2C is currently only supported on Linux + if not sys.platform.lower().startswith("linux"): + raise cv.Invalid( + "I2C is only supported on Linux for the host platform. " + f"Current platform: {sys.platform}" + ) + if CONF_SDA in config or CONF_SCL in config: + raise cv.Invalid( + "'sda' and 'scl' are not supported on host platform; use 'device' instead." + ) + if CONF_SDA_PULLUP_ENABLED in config or CONF_SCL_PULLUP_ENABLED in config: + raise cv.Invalid("Pull-up configuration is not supported on host platform.") + if CONF_DEVICE not in config: + raise cv.Invalid( + "'device' is required for host platform (e.g., /dev/i2c-0)." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): _bus_declare_type, - cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SDA, + esp32="SDA", + esp8266="SDA", + rp2="SDA", + nrf52="SDA", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), - cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number, + cv.SplitDefault( + CONF_SCL, + esp32="SCL", + esp8266="SCL", + rp2="SCL", + nrf52="SCL", + ): pins.internal_gpio_pin_number, cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( cv.only_on_esp32, cv.boolean ), @@ -137,8 +193,9 @@ CONFIG_SCHEMA = cv.All( CONF_FREQUENCY, esp32="50kHz", esp8266="50kHz", - rp2040="50kHz", + rp2="50kHz", nrf52="100kHz", + host="50kHz", ): cv.All( cv.frequency, cv.float_range(min=0, min_included=False), @@ -155,18 +212,30 @@ CONFIG_SCHEMA = cv.All( ), cv.boolean, ), + cv.Optional(CONF_DEVICE): cv.All( + cv.only_on(PLATFORM_HOST), validate_device + ), } ).extend(cv.COMPONENT_SCHEMA), - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2, + PLATFORM_NRF52, + PLATFORM_HOST, + ] + ), validate_config, + validate_host_config, ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: 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" @@ -214,10 +283,21 @@ FINAL_VALIDATE_SCHEMA = _final_validate @coroutine_with_priority(CoroPriority.BUS) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(i2c_ns.using) cg.add_define("USE_I2C") - if CORE.using_zephyr: + if CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + # Re-enable the I2C driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_i2c") + if CORE.is_host: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add(var.set_device(config[CONF_DEVICE])) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + elif CORE.using_zephyr: zephyr_add_prj_conf("I2C", True) i2c = "i2c0" if zephyr_data()[KEY_BOARD] == "xiao_ble": @@ -244,28 +324,43 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], MockObj(f"DEVICE_DT_GET(DT_NODELABEL({i2c}))") ) + await cg.register_component(var, config) + + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) else: var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) + await cg.register_component(var, config) - cg.add(var.set_sda_pin(config[CONF_SDA])) - if CONF_SDA_PULLUP_ENABLED in config: - cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) - cg.add(var.set_scl_pin(config[CONF_SCL])) - if CONF_SCL_PULLUP_ENABLED in config: - cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) + cg.add(var.set_sda_pin(config[CONF_SDA])) + if CONF_SDA_PULLUP_ENABLED in config: + cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED])) + cg.add(var.set_scl_pin(config[CONF_SCL])) + if CONF_SCL_PULLUP_ENABLED in config: + cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED])) - cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) - cg.add(var.set_scan(config[CONF_SCAN])) - if CONF_TIMEOUT in config: - cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) - if CORE.using_arduino and not CORE.is_esp32: - cg.add_library("Wire", None) - if CONF_LOW_POWER_MODE in config: - cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) + cg.add(var.set_frequency(int(config[CONF_FREQUENCY]))) + cg.add(var.set_scan(config[CONF_SCAN])) + if CONF_TIMEOUT in config: + cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) + if CORE.using_arduino and not CORE.is_esp32: + cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) -def i2c_device_schema(default_address): +def i2c_device_schema(default_address: int | None) -> cv.Schema: """Create a schema for a i2c device. :param default_address: The default address of the i2c device, can be None to represent @@ -282,7 +377,7 @@ def i2c_device_schema(default_address): return cv.Schema(schema) -async def register_i2c_device(var, config): +async def register_i2c_device(var: MockObj, config: ConfigType) -> None: """Register an i2c device with the given config. Sets the i2c bus to use and the i2c address. @@ -297,11 +392,11 @@ async def register_i2c_device(var, config): def final_validate_device_schema( name: str, *, - min_frequency: cv.frequency = None, - max_frequency: cv.frequency = None, - min_timeout: cv.time_period = None, - max_timeout: cv.time_period = None, -): + min_frequency: Any = None, + max_frequency: Any = None, + min_timeout: Any = None, + max_timeout: Any = None, +) -> cv.Schema: hub_schema = {} if (min_frequency is not None) and (max_frequency is not None): hub_schema[cv.Required(CONF_FREQUENCY)] = cv.Range( @@ -355,7 +450,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, @@ -365,5 +460,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.ESP32_IDF, }, "i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + "i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE}, } ) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 47a06abe9e..39a6aec774 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include "i2c_bus_arduino.h" #include @@ -9,7 +9,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.arduino"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; @@ -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..71e91e770b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_ESP32) +#if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) #include #include "esphome/core/component.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.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 4aca4f0fae..7ca9537e2d 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.idf"; +static const char *const TAG = "i2c"; // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; 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.cpp b/esphome/components/i2c/i2c_bus_host.cpp new file mode 100644 index 0000000000..303944636b --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -0,0 +1,297 @@ +#ifdef USE_HOST +#if defined(__linux__) + +#include "i2c_bus_host.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace esphome::i2c { + +static const char *const TAG = "i2c"; + +HostI2CBus::~HostI2CBus() { + if (this->file_descriptor_ != -1) { + close(this->file_descriptor_); + this->file_descriptor_ = -1; + } +} + +void HostI2CBus::setup() { + ESP_LOGCONFIG(TAG, "Setting up I2C bus..."); + + // Open I2C device file + this->file_descriptor_ = open(this->device_.c_str(), O_RDWR); + if (this->file_descriptor_ == -1) { + int err = errno; + if (err == ENOENT) { + this->update_error_("not found"); + } else if (err == EACCES) { + this->update_error_("permission denied"); + } else { + this->update_error_(std::string("failed to open: ") + strerror(err)); + } + this->mark_failed(); + return; + } + + this->initialized_ = true; + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + + // Run bus scan if enabled + if (this->scan_) { + this->i2c_scan_(); + } +} + +void HostI2CBus::dump_config() { + ESP_LOGCONFIG(TAG, "I2C Bus:"); + ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str()); + // Bus frequency cannot be set from userspace via i2c-dev; report it as informational only + ESP_LOGCONFIG(TAG, " Frequency: %u Hz (informational; not applied on host)", this->frequency_); + + if (!this->first_error_.empty()) { + ESP_LOGE(TAG, " Setup Error: %s", this->first_error_.c_str()); + } + + if (this->scan_) { + ESP_LOGI(TAG, " Scan Results:"); + for (const auto &s : this->scan_results_) { + if (s.second) { + ESP_LOGI(TAG, " 0x%02X: Found", s.first); + } + } + } +} + +ErrorCode HostI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + if (!this->initialized_) { + ESP_LOGE(TAG, "I2C bus not initialized"); + return ERROR_NOT_INITIALIZED; + } + + ESP_LOGVV(TAG, "I2C write_readv addr=0x%02X write=%zu read=%zu", address, write_count, read_count); + + // Handle special case: probe (no write data, no read data) + // This is used for device detection during bus scanning + if (write_count == 0 && read_count == 0) { + struct i2c_msg msg; + msg.addr = address; + msg.flags = 0; + msg.len = 0; + msg.buf = nullptr; + + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = &msg; + rdwr_data.nmsgs = 1; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + // If I2C_RDWR not supported, try SMBus Quick command (what i2cdetect uses) + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGVV(TAG, "I2C_RDWR probe failed, trying SMBus Quick for addr=0x%02X", address); + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Use I2C_SMBUS ioctl with Quick command + union i2c_smbus_data data; + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = 0; + args.size = I2C_SMBUS_QUICK; + args.data = &data; + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + return this->map_errno_to_error_code_(errno); + } + return ERROR_OK; + } + return this->map_errno_to_error_code_(err); + } + return ERROR_OK; + } + + // i2c_msg.len is a 16-bit field; reject transfers that would silently truncate + if (write_count > UINT16_MAX || read_count > UINT16_MAX) { + ESP_LOGE(TAG, "I2C transfer too large: write=%zu read=%zu (max %u)", write_count, read_count, + (unsigned) UINT16_MAX); + return ERROR_TOO_LARGE; + } + + // Prepare messages for combined write-read transaction + struct i2c_msg msgs[2]; + int num_msgs = 0; + + // Add write message if write data present + if (write_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = 0; // Write + msgs[num_msgs].len = write_count; + msgs[num_msgs].buf = const_cast(write_buffer); + num_msgs++; + } + + // Add read message if read data requested + if (read_count > 0) { + msgs[num_msgs].addr = address; + msgs[num_msgs].flags = I2C_M_RD; // Read + msgs[num_msgs].len = read_count; + msgs[num_msgs].buf = read_buffer; + num_msgs++; + } + + // Execute I2C transaction + struct i2c_rdwr_ioctl_data rdwr_data; + rdwr_data.msgs = msgs; + rdwr_data.nmsgs = num_msgs; + + int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data); + if (ret < 0) { + int err = errno; + if (err == EOPNOTSUPP || err == ENOSYS) { + ESP_LOGV(TAG, "I2C_RDWR not supported, using I2C_SLAVE fallback for addr=0x%02X", address); // NOLINT + if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT + ESP_LOGV(TAG, "I2C_SLAVE ioctl failed: %s", strerror(errno)); // NOLINT + return this->map_errno_to_error_code_(errno); + } + // Perform write if needed + if (write_count > 0) { + ssize_t written = ::write(this->file_descriptor_, write_buffer, write_count); + if (written != (ssize_t) write_count) { + int write_err = errno; + // If write() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (write_err == EOPNOTSUPP || write_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE write not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for writes up to 32 bytes + // Standard SMBus mapping: first byte is command, remaining bytes are data + if (write_count < 1) { + ESP_LOGE(TAG, "Write size too small for I2C_SMBUS"); + return ERROR_INVALID_ARGUMENT; + } + if (write_count > I2C_SMBUS_BLOCK_MAX + 1) { + ESP_LOGE(TAG, "Write size %zu exceeds I2C_SMBUS_BLOCK_MAX+1 (%d)", write_count, I2C_SMBUS_BLOCK_MAX + 1); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + // Standard SMBus: first byte = command, rest = data + uint8_t command = write_buffer[0]; + size_t data_len = write_count - 1; + data.block[0] = data_len; + if (data_len > 0) { + memcpy(&data.block[1], write_buffer + 1, data_len); + } + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_WRITE; + args.command = command; + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS write failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + } else { + ESP_LOGV(TAG, "I2C write failed: %s", strerror(write_err)); + return this->map_errno_to_error_code_(write_err); + } + } + } + // Perform read if needed + if (read_count > 0) { + ssize_t bytes_read = ::read(this->file_descriptor_, read_buffer, read_count); + if (bytes_read != (ssize_t) read_count) { + int read_err = errno; + // If read() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort + if (read_err == EOPNOTSUPP || read_err == ENOSYS) { + ESP_LOGV(TAG, "I2C_SLAVE read not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT + // Use I2C_SMBUS_I2C_BLOCK_DATA for reads up to 32 bytes + if (read_count > I2C_SMBUS_BLOCK_MAX) { + ESP_LOGE(TAG, "Read size %zu exceeds I2C_SMBUS_BLOCK_MAX (%d)", read_count, I2C_SMBUS_BLOCK_MAX); + return ERROR_INVALID_ARGUMENT; + } + union i2c_smbus_data data; + data.block[0] = read_count; + + struct i2c_smbus_ioctl_data args; + args.read_write = I2C_SMBUS_READ; + args.command = 0; // Start register/command + args.size = I2C_SMBUS_I2C_BLOCK_DATA; + args.data = &data; + + ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args); + if (ret < 0) { + ESP_LOGV(TAG, "I2C_SMBUS read failed: %s", strerror(errno)); + return this->map_errno_to_error_code_(errno); + } + // I2C_SMBUS_I2C_BLOCK_DATA returns the actual byte count in block[0]; + // a short read means we did not receive all requested bytes + if (data.block[0] < read_count) { + ESP_LOGV(TAG, "I2C_SMBUS short read: got %u, expected %zu", data.block[0], read_count); + return ERROR_NOT_ACKNOWLEDGED; + } + // Copy data from SMBus buffer to output buffer + memcpy(read_buffer, &data.block[1], read_count); + } else { + ESP_LOGV(TAG, "I2C read failed: %s", strerror(read_err)); + return this->map_errno_to_error_code_(read_err); + } + } + } + ESP_LOGVV(TAG, "I2C transaction successful (I2C_SLAVE method)"); // NOLINT + return ERROR_OK; + } + ESP_LOGV(TAG, "I2C transaction failed: %s", strerror(err)); + return this->map_errno_to_error_code_(err); + } + + ESP_LOGVV(TAG, "I2C transaction successful"); + return ERROR_OK; +} + +ErrorCode HostI2CBus::map_errno_to_error_code_(int err) { + switch (err) { + case ENXIO: + return ERROR_NOT_ACKNOWLEDGED; + case ETIMEDOUT: + return ERROR_TIMEOUT; + case EINVAL: + return ERROR_INVALID_ARGUMENT; + case ENODEV: + case ENOTTY: + return ERROR_NOT_INITIALIZED; + case EOPNOTSUPP: + case ENOSYS: + // Operation not supported - some I2C adapters don't support zero-length transactions + ESP_LOGVV(TAG, "I2C adapter does not support this operation (likely zero-length probe)"); + return ERROR_NOT_ACKNOWLEDGED; + default: + ESP_LOGV(TAG, "Unmapped error code: %d (%s)", err, strerror(err)); + return ERROR_UNKNOWN; + } +} + +void HostI2CBus::update_error_(const std::string &error) { + if (this->first_error_.empty()) { + this->first_error_ = error; + } + ESP_LOGE(TAG, "[%s] %s", this->device_.c_str(), error.c_str()); +} + +} // namespace esphome::i2c + +#else +#error "HostI2CBus is only supported on Linux" +#endif // defined(__linux__) +#endif // USE_HOST diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h new file mode 100644 index 0000000000..8064016a52 --- /dev/null +++ b/esphome/components/i2c/i2c_bus_host.h @@ -0,0 +1,41 @@ +#pragma once + +#ifdef USE_HOST + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "i2c_bus.h" + +namespace esphome::i2c { + +class HostI2CBus final : public I2CBus, public Component { + public: + ~HostI2CBus() override; + + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BUS; } + + ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override; + + void set_device(const std::string &device) { this->device_ = device; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + + const std::string &get_device() const { return this->device_; } + + protected: + void update_error_(const std::string &error); + ErrorCode map_errno_to_error_code_(int err); + + std::string device_; + uint32_t frequency_{50000}; + int file_descriptor_{-1}; + bool initialized_{false}; + std::string first_error_; +}; + +} // namespace esphome::i2c + +#endif // USE_HOST diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 1eb9944dcb..ffdd2ba8bb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.zephyr"; +static const char *const TAG = "i2c"; static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { 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/__init__.py b/esphome/components/i2c_device/__init__.py index 531c363bd1..f890fefdb9 100644 --- a/esphome/components/i2c_device/__init__.py +++ b/esphome/components/i2c_device/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@gabest11"] @@ -20,7 +21,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(i2c.i2c_device_schema(None)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/i2s_audio/__init__.py index 951b8c0498..c5e82beb46 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -21,14 +21,16 @@ from esphome.components.esp32.const import ( import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] MULTI_CONF = True CONF_PDM = "pdm" +CONF_PDM_DSR = "pdm_dsr" CONF_ADC_TYPE = "adc_type" CONF_I2S_DOUT_PIN = "i2s_dout_pin" @@ -144,7 +146,7 @@ I2S_MCLK_MULTIPLE = { _validate_bits = cv.float_with_unit("bits", "bit") -def validate_mclk_divisible_by_3(config): +def validate_mclk_divisible_by_3(config: ConfigType) -> ConfigType: if config[CONF_BITS_PER_SAMPLE] == 24 and config[CONF_MCLK_MULTIPLE] % 3 != 0: raise cv.Invalid( f"{CONF_MCLK_MULTIPLE} must be divisible by 3 when bits per sample is 24" @@ -158,7 +160,7 @@ def i2s_audio_component_schema( default_sample_rate: int, default_channel: str, default_bits_per_sample: str, -): +) -> cv.Schema: return cv.Schema( { cv.GenerateID(): cv.declare_id(class_), @@ -170,7 +172,7 @@ def i2s_audio_component_schema( min=1 ), cv.Optional(CONF_BITS_PER_SAMPLE, default=default_bits_per_sample): cv.All( - _validate_bits, cv.one_of(*I2S_BITS_PER_SAMPLE) + _validate_bits, cv.int_, cv.one_of(*I2S_BITS_PER_SAMPLE) ), cv.Optional(CONF_I2S_MODE, default=CONF_PRIMARY): cv.one_of( *I2S_MODE_OPTIONS, lower=True @@ -181,7 +183,7 @@ def i2s_audio_component_schema( ) -async def register_i2s_audio_component(var, config): +async def register_i2s_audio_component(var: MockObj, config: ConfigType) -> None: await cg.register_parented(var, config[CONF_I2S_AUDIO_ID]) cg.add(var.set_i2s_role(I2S_ROLE_OPTIONS[config[CONF_I2S_MODE]])) slot_mode = config[CONF_CHANNEL] @@ -259,7 +261,7 @@ def _assign_ports() -> None: next_port += 1 -def _final_validate(_): +def _final_validate(_: ConfigType) -> None: i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] variant = get_esp32_variant() if variant not in I2S_PORTS: @@ -274,7 +276,7 @@ def _final_validate(_): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 1392d1d4ec..c217317237 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, ) +from esphome.types import ConfigType from .. import ( CONF_ADC_TYPE, @@ -17,6 +18,7 @@ from .. import ( CONF_LEFT, CONF_MONO, CONF_PDM, + CONF_PDM_DSR, CONF_RIGHT, I2SAudioIn, i2s_audio_component_schema, @@ -38,8 +40,14 @@ I2SAudioMicrophone = i2s_audio_ns.class_( INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4] +i2s_pdm_dsr_t = cg.global_ns.enum("i2s_pdm_dsr_t") +I2S_PDM_DSR = { + 8: i2s_pdm_dsr_t.I2S_PDM_DSR_8S, + 16: i2s_pdm_dsr_t.I2S_PDM_DSR_16S, +} -def _validate_esp32_variant(config): + +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_ADC_TYPE] == "external": if config[CONF_PDM] and variant not in PDM_VARIANTS: @@ -58,13 +66,13 @@ def _validate_esp32_variant(config): raise NotImplementedError -def _validate_channel(config): +def _validate_channel(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] == CONF_MONO: raise cv.Invalid(f"I2S microphone does not support {CONF_MONO}.") return config -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -73,7 +81,7 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: audio.set_stream_limits( min_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), @@ -111,6 +119,9 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_I2S_DIN_PIN): pins.internal_gpio_input_pin_number, cv.Optional(CONF_PDM, default=False): cv.boolean, + cv.Optional(CONF_PDM_DSR, default=8): cv.enum( + I2S_PDM_DSR, int=True + ), } ), }, @@ -124,7 +135,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_ADC_TYPE] == "internal": raise cv.Invalid( "Internal ADC is no longer supported. Use an external I2S microphone instead." @@ -134,7 +145,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) @@ -142,5 +153,7 @@ async def to_code(config): cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN])) cg.add(var.set_pdm(config[CONF_PDM])) + if esp32.get_esp32_variant() in PDM_VARIANTS: + cg.add(var.set_pdm_dsr(config[CONF_PDM_DSR])) cg.add(var.set_correct_dc_offset(config[CONF_CORRECT_DC_OFFSET])) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 66ca32b830..c577ed092e 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -128,7 +128,7 @@ bool I2SAudioMicrophone::start_driver_() { .sample_rate_hz = this->sample_rate_, .clk_src = clk_src, .mclk_multiple = this->mclk_multiple_, - .dn_sample_mode = I2S_PDM_DSR_8S, + .dn_sample_mode = this->pdm_dsr_, }; i2s_pdm_rx_slot_config_t slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, this->slot_mode_); @@ -245,7 +245,7 @@ void I2SAudioMicrophone::mic_task(void *params) { while (!(xEventGroupGetBits(this_microphone->event_group_) & MicrophoneEventGroupBits::COMMAND_STOP)) { if (this_microphone->data_callbacks_.size() > 0) { samples.resize(bytes_to_read); - size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * pdMS_TO_TICKS(READ_DURATION_MS)); + size_t bytes_read = this_microphone->read_(samples.data(), bytes_to_read, 2 * READ_DURATION_MS); samples.resize(bytes_read); if (this_microphone->correct_dc_offset_) { this_microphone->fix_dc_offset_(samples); @@ -318,12 +318,11 @@ void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { } } -size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait) { +size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) { size_t bytes_read = 0; - // i2s_channel_read expects the timeout value in ms, not ticks - esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, pdTICKS_TO_MS(ticks_to_wait)); - if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (ticks_to_wait != 0))) { - // Ignore ESP_ERR_TIMEOUT if ticks_to_wait = 0, as it will read the data on the next call + esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms); + if ((err != ESP_OK) && ((err != ESP_ERR_TIMEOUT) || (timeout_ms != 0))) { + // Ignore ESP_ERR_TIMEOUT if timeout_ms = 0, as it will read the data on the next call if (!this->status_has_warning()) { // Avoid spamming the logs with the error message if its repeated ESP_LOGW(TAG, "Read error: %s", esp_err_to_name(err)); @@ -331,7 +330,7 @@ size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, TickType_t ticks_to_w this->status_set_warning(); return 0; } - if ((bytes_read == 0) && (ticks_to_wait > 0)) { + if ((bytes_read == 0) && (timeout_ms > 0)) { this->status_set_warning(); return 0; } diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 06f2de7610..37895ac4e7 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; @@ -29,6 +29,10 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub void set_pdm(bool pdm) { this->pdm_ = pdm; } +#if SOC_I2S_SUPPORTS_PDM_RX + void set_pdm_dsr(i2s_pdm_dsr_t pdm_dsr) { this->pdm_dsr_ = pdm_dsr; } +#endif + protected: /// @brief Starts the I2S driver. Updates the ``audio_stream_info_`` member variable with the current setttings. /// @return True if succesful, false otherwise @@ -42,7 +46,7 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub /// @param data void fix_dc_offset_(std::vector &data); - size_t read_(uint8_t *buf, size_t len, TickType_t ticks_to_wait); + size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms); /// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings. void configure_stream_settings_(); @@ -57,6 +61,9 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub gpio_num_t din_pin_{I2S_GPIO_UNUSED}; i2s_chan_handle_t rx_handle_; bool pdm_{false}; +#if SOC_I2S_SUPPORTS_PDM_RX + i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; +#endif bool correct_dc_offset_; bool locked_driver_{false}; diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 759cc40ca9..4dc15681bf 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -1,6 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import audio, esp32, speaker +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -13,6 +14,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +from esphome.types import ConfigType from .. import ( CONF_I2S_DOUT_PIN, @@ -78,7 +80,7 @@ I2C_COMM_FMT_OPTIONS = { INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] -def _set_num_channels_from_config(config): +def _set_num_channels_from_config(config: ConfigType) -> ConfigType: if config[CONF_CHANNEL] in (CONF_MONO, CONF_LEFT, CONF_RIGHT): config[CONF_NUM_CHANNELS] = 1 else: @@ -87,59 +89,69 @@ def _set_num_channels_from_config(config): return config -def _set_stream_limits(config): +def _set_stream_limits(config: ConfigType) -> ConfigType: if config.get(CONF_SPDIF_MODE, False): - # SPDIF mode: fixed to 16-bit stereo at configured sample rate + # SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( min_bits_per_sample=16, - max_bits_per_sample=16, + max_bits_per_sample=32, min_channels=2, max_channels=2, min_sample_rate=config.get(CONF_SAMPLE_RATE), max_sample_rate=config.get(CONF_SAMPLE_RATE), )(config) - elif config[CONF_I2S_MODE] == CONF_PRIMARY: - # Primary mode has modifiable stream settings + return config + + # The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest + # 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. audio.set_stream_limits( - min_bits_per_sample=8, - max_bits_per_sample=32, + min_bits_per_sample=min_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=8, - max_bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), + min_bits_per_sample=min_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), max_sample_rate=config.get(CONF_SAMPLE_RATE), - ) + )(config) return config -def _select_speaker_class(config): +def _select_speaker_class(config: ConfigType) -> ConfigType: """Override ID type when SPDIF mode is enabled.""" if config.get(CONF_SPDIF_MODE, False): config[CONF_ID].type = I2SAudioSpeakerSPDIF return config -def _validate_esp32_variant(config): +def _validate_esp32_variant(config: ConfigType) -> ConfigType: variant = esp32.get_esp32_variant() if config[CONF_DAC_TYPE] == "internal": if variant not in INTERNAL_DAC_VARIANTS: raise cv.Invalid(f"{variant} does not have an internal DAC") - elif ( - variant == esp32.VARIANT_ESP32 - and config.get(CONF_BITS_PER_SAMPLE) == 8 - and config.get(CONF_CHANNEL) in (CONF_MONO, CONF_LEFT, CONF_RIGHT) - ): - raise cv.Invalid("8-bit mono mode is not supported on ESP32") + elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8: + # The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an + # 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it + # at config time rather than emitting corrupted output at runtime. + raise cv.Invalid("8-bit audio is not supported on the original ESP32") return config @@ -197,7 +209,7 @@ CONFIG_SCHEMA = cv.All( ) -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: if config[CONF_DAC_TYPE] == "internal": raise cv.Invalid( "Internal DAC is no longer supported. Use an external I2S DAC instead." @@ -213,9 +225,6 @@ def _final_validate(config): ) if config[CONF_CHANNEL] != CONF_STEREO: raise cv.Invalid("SPDIF mode only supports stereo channel configuration") - # bits_per_sample is converted to float by the schema - if config[CONF_BITS_PER_SAMPLE] != 16: - raise cv.Invalid("SPDIF mode only supports 16 bits per sample") if not config[CONF_USE_APLL]: raise cv.Invalid( "SPDIF mode requires 'use_apll: true' for accurate clock generation" @@ -231,7 +240,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await register_i2s_audio_component(var, config) @@ -253,3 +262,13 @@ async def to_code(config): if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + + +# The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, +# set only when spdif_mode is enabled. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "spdif_encoder.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + "i2s_audio_spdif.cpp": "USE_I2S_AUDIO_SPDIF_MODE", + } +) diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index 8f67562a77..ed5145d4b0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_. xQueueReset(this->write_records_queue_); - const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT; - // 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 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; - // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames + // For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz), + // not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size. const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES; const size_t bytes_to_fill_single_dma_buffer = this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer); + const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT; + + // 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. Ensure it is at least large enough to cover all DMA buffers. + const size_t requested_ring_buffer_bytes = + (this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; + const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes); bool successful_setup = false; std::unique_ptr audio_source; @@ -177,7 +177,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // on_sent events drain in lockstep without crediting any audio frames. this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS)); + // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. + esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); if (preload_err != ESP_OK) { break; // DMA preload buffer full or error } @@ -403,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()) { @@ -410,8 +413,9 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s this->sample_rate_, audio_stream_info.get_sample_rate()); return ESP_ERR_NOT_SUPPORTED; } - if (audio_stream_info.get_bits_per_sample() != 16) { - ESP_LOGE(TAG, "Only supports 16 bits per sample"); + const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample(); + if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) { + ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample); return ESP_ERR_NOT_SUPPORTED; } if (audio_stream_info.get_channels() != 2) { @@ -419,11 +423,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s 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_) { - ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration"); - return ESP_ERR_NOT_SUPPORTED; - } + // Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire. + this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8); if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 680ca069c0..5e271e671e 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -2,6 +2,7 @@ #ifdef USE_ESP32 +#include #include #include "esphome/components/audio/audio.h" @@ -138,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); @@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { i2s_channel_disable(this->tx_handle_); i2s_del_channel(this->tx_handle_); this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } this->parent_->unlock(); } @@ -344,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 20bb05e322..adb6ca5e3f 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -19,7 +19,6 @@ namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations -static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15; static constexpr size_t TASK_STACK_SIZE = 4096; static constexpr ssize_t TASK_PRIORITY = 19; @@ -135,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); @@ -157,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 e69601e87a..17c93763d6 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include +#include #include "esphome/components/audio/audio.h" #include "esphome/components/audio/audio_transfer_buffer.h" @@ -12,11 +13,23 @@ #include "esp_timer.h" +// esp-audio-libs +#include + namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker.std"; -static constexpr size_t DMA_BUFFERS_COUNT = 4; +static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; +static constexpr size_t DMA_BUFFERS_COUNT = 5; +// ESP-IDF clamps each DMA descriptor to this many bytes when allocating the channel (see i2s_get_buf_size in +// the I2S driver). Mirror its target-dependent selection so the requested dma_frame_num stays in range; the +// speaker task reads the size actually allocated back from the driver rather than relying on this value. +#if SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_64B_ALIGNED; +#else +static constexpr size_t I2S_DMA_BUFFER_MAX_SIZE = DMA_DESCRIPTOR_BUFFER_MAX_SIZE_4B_ALIGNED; +#endif // Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight, // doubled so that a transient backlog never overruns the queue (which would desync the lockstep // invariant between i2s_event_queue_ and write_records_queue_). @@ -26,6 +39,17 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2; // without masking real failures. static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1)); +// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF +// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust +// it (e.g. cache-line rounding on some targets), so the speaker task reads the size actually allocated back +// from the driver instead of assuming this value. Clamping here keeps the request in range and avoids a +// noisy ESP-IDF "dma frame num is out of dma buffer size" warning at high sample rates or bit depths. +static uint32_t dma_buffer_frames(const audio::AudioStreamInfo &stream_info) { + const uint32_t frames_from_duration = stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + const uint32_t max_frames = I2S_DMA_BUFFER_MAX_SIZE / stream_info.frames_to_bytes(1); + return std::min(frames_from_duration, max_frames); +} + void I2SAudioSpeaker::dump_config() { I2SAudioSpeakerBase::dump_config(); const char *fmt_str; @@ -41,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() { @@ -50,14 +80,37 @@ 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; - const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS); - const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer); + + // 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 + // allocated so preload, silence padding, and the write/event lockstep all match it exactly. The channel is + // in the READY state here because start_i2s_driver() initialized it before this task was created. + size_t dma_buffer_bytes; + i2s_chan_info_t chan_info; + if (i2s_channel_get_info(this->tx_handle_, &chan_info) == ESP_OK && chan_info.total_dma_buf_size > 0) { + // total_dma_buf_size spans all DMA_BUFFERS_COUNT descriptors and is an exact multiple of the count. + 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->output_stream_info_.frames_to_bytes(dma_buffer_frames(this->output_stream_info_)); + } + // 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; @@ -71,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 @@ -203,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(); } } @@ -259,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++; } } @@ -300,19 +371,39 @@ 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"); + // 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. 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 if (!this->parent_->try_lock()) { ESP_LOGE(TAG, "Parent bus is busy"); return ESP_ERR_INVALID_STATE; } - uint32_t dma_buffer_length = audio_stream_info.ms_to_frames(DMA_BUFFER_DURATION_MS); + // 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; @@ -352,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/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 42a72346cc..30146e0a70 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start) static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel // BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33 -// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33. +// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero. static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33; // Constexpr BMC encoder for compile-time LUT generation. @@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) { return bmc; } -// 4-bit BMC lookup table: 16 entries (16 bytes in flash) -// Index: 4-bit data value (0-15), always phase=true start +// Compile-time parity helper (constexpr-friendly, runs only at LUT build time). +static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) { + uint32_t p = 0; + for (uint32_t b = 0; b < num_bits; b++) + p ^= (value >> b) & 1u; + return p; +} + +// Combined BMC + phase-delta lookup tables. +// Each entry packs the BMC pattern (lower bits, phase=high start) together with +// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0). +// XORing the delta into the running phase mask propagates parity across chunks +// without an explicit popcount. + +// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash. +// Bits 0-7 : 8-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_4 = [] { - std::array t{}; - for (uint32_t i = 0; i < 16; i++) - t[i] = static_cast(bmc_lut_encode(i, 4)); + std::array t{}; + for (uint32_t i = 0; i < 16; i++) { + uint32_t bmc = bmc_lut_encode(i, 4); + uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); -// 8-bit BMC lookup table: 256 entries (512 bytes in flash) -// Index: 8-bit data value (0-255), always phase=true start +// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash. +// Bits 0-15 : 16-bit BMC pattern (phase=high start) +// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0) static constexpr auto BMC_LUT_8 = [] { - std::array t{}; - for (uint32_t i = 0; i < 256; i++) - t[i] = bmc_lut_encode(i, 8); + std::array t{}; + for (uint32_t i = 0; i < 256; i++) { + uint32_t bmc = bmc_lut_encode(i, 8); + uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u; + t[i] = bmc | delta; + } return t; }(); @@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() { } ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES); - // Build initial channel status block with default sample rate + // Build initial channel status block with default sample rate and width this->build_channel_status_(); this->reset(); @@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() { void SPDIFEncoder::reset() { this->spdif_block_ptr_ = this->spdif_block_buf_.get(); this->frame_in_block_ = 0; - this->is_left_channel_ = true; + this->block_buf_is_silence_block_ = false; } void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { @@ -84,31 +106,27 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) { } } +void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) { + if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) { + ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample); + return; + } + if (this->bytes_per_sample_ != bytes_per_sample) { + this->bytes_per_sample_ = bytes_per_sample; + this->build_channel_status_(); + // Discard any partial block built at the previous width so we never mix widths on the wire. + this->reset(); + ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8); + } +} + void SPDIFEncoder::build_channel_status_() { // IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes) - // Transmitted LSB-first within each byte, one bit per frame via C bit - // - // Byte 0: Control bits - // Bit 0: 0 = Consumer format (not professional AES3) - // Bit 1: 0 = PCM audio (not non-audio data like AC3) - // Bit 2: 0 = No copyright assertion - // Bits 3-5: 000 = No pre-emphasis - // Bits 6-7: 00 = Mode 0 (basic consumer format) - // - // Byte 1: Category code (0x00 = general, 0x01 = CD, etc.) - // - // Byte 2: Source/channel numbers - // Bits 0-3: Source number (0 = unspecified) - // Bits 4-7: Channel number (0 = unspecified) - // - // Byte 3: Sample frequency and clock accuracy - // Bits 0-3: Sample frequency code - // Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32) - // Bits 6-7: Reserved (0) - // - // Bytes 4-23: Reserved (zeros for basic compliance) + // Transmitted LSB-first within each byte, one bit per frame via C bit. + + // Any cached silence block was built for the previous channel status; it is now stale. + this->block_buf_is_silence_block_ = false; - // Clear all bytes first this->channel_status_.fill(0); // Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0 @@ -140,132 +158,148 @@ void SPDIFEncoder::build_channel_status_() { // Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5 this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0 - // Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.) + // Byte 4: Word length encoding (IEC 60958-3 consumer) + // bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits) + // bits 1-3: word length code relative to the max + // For our supported widths: + // 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20" + // 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24" + // 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code. + uint8_t word_length_code; + switch (this->bytes_per_sample_) { + case 2: + word_length_code = 0x02; + break; + case 3: // Shared case + case 4: + word_length_code = 0x0D; + break; + default: + word_length_code = 0x00; // not specified + break; + } + this->channel_status_[4] = word_length_code; } -HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) { - // ============================================================================ - // Build raw 32-bit subframe (IEC 60958 format) - // ============================================================================ - // Bit layout: - // Bits 0-3: Preamble (handled separately, not in raw_subframe) - // Bits 4-7: Auxiliary audio data (zeros for 16-bit audio) - // Bits 8-11: Audio LSB extension (zeros for 16-bit audio) - // Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field) - // Bit 28: V (Validity) - 0 = valid audio - // Bit 29: U (User data) - 0 - // Bit 30: C (Channel status) - from channel status block - // Bit 31: P (Parity) - even parity over bits 4-31 - // ============================================================================ +// Extract the C bit for the given frame from channel_status_ and shift it into bit 30 +// so it can be OR'd directly into a raw subframe. +ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array &channel_status, + uint32_t frame) { + return static_cast((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30; +} - // Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB) - uint32_t raw_subframe = (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); +// ============================================================================ +// IEC 60958 subframe bit layout +// ============================================================================ +// Bits 0-3: Preamble (handled separately, not in raw_subframe) +// Bits 4-7: Auxiliary audio data / 24-bit audio LSB +// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit) +// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode) +// Bit 28: V (Validity) - 0 = valid audio +// Bit 29: U (User data) - 0 +// Bit 30: C (Channel status) - from channel status block +// Bit 31: P (Parity) - even parity over bits 4-31 +// ============================================================================ - // V = 0 (valid audio), U = 0 (no user data) - // C = channel status bit for current frame (same bit used for both L and R subframes) - bool c_bit = this->get_channel_status_bit_(this->frame_in_block_); - if (c_bit) { - raw_subframe |= (1U << 30); +// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes. +// Caller is responsible for OR-ing in the C bit and parity. +template ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) { + static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample"); + if constexpr (Bps == 2) { + // 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27. + return (static_cast(pcm_sample[1]) << 20) | (static_cast(pcm_sample[0]) << 12); + } else if constexpr (Bps == 3) { + // 24-bit input: full 24-bit audio field, bits 4-27. + return (static_cast(pcm_sample[2]) << 20) | (static_cast(pcm_sample[1]) << 12) | + (static_cast(pcm_sample[0]) << 4); + } else { // Bps == 4 + // 32-bit input truncated to 24-bit: drop the lowest byte. + return (static_cast(pcm_sample[3]) << 20) | (static_cast(pcm_sample[2]) << 12) | + (static_cast(pcm_sample[1]) << 4); } +} - // Calculate even parity over bits 4-30 - // This ensures consistent BMC ending phase regardless of audio content - uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30) - uint32_t ones_count = __builtin_popcount(bits_4_30); - uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even - raw_subframe |= parity << 31; // Set P bit to make total even +// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes +// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is +// derived from the cumulative parity-mask delta of the per-byte LUT lookups. +// +// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7. +// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first. +// All preambles end at phase HIGH, so phase=true at the start of bit 4. +// +// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each +// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the +// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to +// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the +// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit +// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output +// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower +// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`. +template +ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) { + if constexpr (Bps == 2) { + // 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants. + // Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even), + // so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity. + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = lut_n & 0xFFu; + uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0 - // ============================================================================ - // Select preamble based on position in block and channel - // ============================================================================ - // B = block start (left channel, frame 0 of 192-frame block) - // M = left channel (frames 1-191) - // W = right channel (all frames) - uint8_t preamble; - if (this->is_left_channel_) { - preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + // phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31. + bmc_24_31 ^= phase_mask & 1u; + + dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } else { - preamble = PREAMBLE_W; + // 24-bit (and 32-bit truncated) path: bits 4-11 are live audio. + uint32_t byte_lo = (raw_subframe >> 4) & 0xFF; + uint32_t lut_l = BMC_LUT_8[byte_lo]; + uint32_t bmc_4_11 = lut_l & 0xFFFFu; + uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0 + + uint32_t nibble = (raw_subframe >> 12) & 0xF; + uint32_t lut_n = BMC_LUT_4[nibble]; + uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu); + phase_mask ^= lut_n >> 16; + + uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; + uint32_t lut_m = BMC_LUT_8[byte_mid]; + uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_m >> 16; + + uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition + uint32_t lut_h = BMC_LUT_8[byte_hi]; + uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask; + phase_mask ^= lut_h >> 16; + bmc_24_31 ^= phase_mask & 1u; + + // word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15) + // word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31) + dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast(preamble) << 24); + dst[1] = bmc_24_31 | (bmc_16_23 << 16); } +} - // ============================================================================ - // BMC encode the data portion (bits 4-31) using lookup tables - // ============================================================================ - // The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15. - // This applies to BOTH word[0] and word[1]. - // - // word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15] - // For correct S/PDIF subframe order (preamble → aux → audio): - // - bits 16-23: preamble (8 BMC bits) - // - bits 24-31: BMC(subframe bits 4-7) - first aux nibble - // - bits 0-7: BMC(subframe bits 8-11) - second aux nibble - // - bits 8-15: BMC(subframe bits 12-15) - audio low nibble - // - // word[1] transmission order: [16-31] → [0-15] - // For correct S/PDIF subframe order: - // - bits 16-31: BMC(subframe bits 16-23) - audio mid byte - // - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP - // ============================================================================ - - // All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio; - // two zero nibbles flip phase 8 times total → back to HIGH. - // So bits 12-15 always start encoding at phase=true. - - // Bits 12-15: 4-bit LUT lookup (always phase=true start) - uint32_t nibble = (raw_subframe >> 12) & 0xF; - uint32_t bmc_12_15 = BMC_LUT_4[nibble]; - - // Phase tracking via branchless XOR mask: - // - 0x0000 means phase=true (use LUT value directly) - // - 0xFFFF means phase=false (complement LUT value) - // End phase = start XOR (popcount & 1) since zero-bits flip phase, - // and for even bit widths: #zeros parity == popcount parity. - uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF; - - // Bits 16-23: 8-bit LUT lookup with phase correction - uint32_t byte_mid = (raw_subframe >> 16) & 0xFF; - uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask; - phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF; - - // Bits 24-31: 8-bit LUT lookup with phase correction - uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; - uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask; - - // ============================================================================ - // Combine with correct positioning for I2S transmission - // ============================================================================ - // I2S with halfword swap: transmits bits 16-31, then bits 0-15. - // Within each halfword, MSB (highest bit) is transmitted first. - // - // For upper halfword (bits 16-31): bit 31 → bit 16 - // For lower halfword (bits 0-15): bit 15 → bit 0 - // - // Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15 - // - // word[0] layout for correct transmission: - // bits 24-31: preamble (transmitted 1st, as MSB of upper halfword) - // bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7) - // bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11) - // bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble) - // - // word[1] layout: - // bits 16-31: bmc_16_23 (transmitted 5th) - // bits 0-15: bmc_24_31 (transmitted 6th) - this->spdif_block_ptr_[0] = - bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast(preamble) << 24); - this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16); - this->spdif_block_ptr_ += 2; - - // ============================================================================ - // Update position tracking - // ============================================================================ - if (!this->is_left_channel_) { - // Completed a stereo frame, advance frame counter - if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { - this->frame_in_block_ = 0; - } +template void SPDIFEncoder::encode_silence_frame_() { + static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0}; + uint32_t raw = build_raw_subframe(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_); + uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M; + bmc_encode_subframe(raw, preamble_l, this->spdif_block_ptr_); + bmc_encode_subframe(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2); + this->spdif_block_ptr_ += 4; + if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) { + this->frame_in_block_ = 0; } - this->is_left_channel_ = !this->is_left_channel_; } esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { @@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) { return err; } -size_t SPDIFEncoder::get_pending_pcm_bytes() const { - if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) { - return 0; +template +HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, + uint32_t *blocks_sent, size_t *bytes_consumed) { + const uint8_t *pcm_data = src; + const uint8_t *const pcm_end = src + size; + uint32_t block_count = 0; + + // Hot state lives in locals so the compiler can keep it in registers across the + // per-frame encoding work; byte writes through block_ptr may alias the member fields, + // which would block register allocation if the encoding read them directly from this->*. + uint32_t *block_ptr = this->spdif_block_ptr_; + uint32_t *const block_buf = this->spdif_block_buf_.get(); + uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32; + uint32_t frame = this->frame_in_block_; + const std::array &channel_status = this->channel_status_; + + auto save_state = [&]() { + this->spdif_block_ptr_ = block_ptr; + this->frame_in_block_ = static_cast(frame); + }; + + auto report_out_params = [&]() { + if (blocks_sent != nullptr) + *blocks_sent = block_count; + if (bytes_consumed != nullptr) + *bytes_consumed = pcm_data - src; + }; + + // Send a completed block if the buffer is full, propagating any error. + // send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it + // unchanged on error -- mirror both behaviors in our local block_ptr. + auto maybe_send = [&]() -> esp_err_t { + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + return ESP_OK; + }; + + // Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only + // buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from + // 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or + // preamble branch. The encoding body is inlined here so block_ptr lives in a register + // for the duration of the loop. + while (pcm_data + 2 * Bps <= pcm_end) { + if (frame == 0) { + esp_err_t err = maybe_send(); + if (err != ESP_OK) + return err; + + uint32_t c_bit = c_bit_for_frame(channel_status, 0); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_B, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + frame = 1; + pcm_data += 2 * Bps; + } + + // The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The + // input-size bound is folded into end_frame so a single `frame < end_frame` test + // governs termination. + uint32_t input_frames = static_cast(pcm_end - pcm_data) / (2u * Bps); + uint32_t end_frame = SPDIF_BLOCK_SAMPLES; + if (frame + input_frames < end_frame) + end_frame = frame + input_frames; + + while (frame < end_frame) { + uint32_t c_bit = c_bit_for_frame(channel_status, frame); + uint32_t raw_l = build_raw_subframe(pcm_data) | c_bit; + uint32_t raw_r = build_raw_subframe(pcm_data + Bps) | c_bit; + bmc_encode_subframe(raw_l, PREAMBLE_M, block_ptr); + bmc_encode_subframe(raw_r, PREAMBLE_W, block_ptr + 2); + block_ptr += 4; + ++frame; + pcm_data += 2 * Bps; + } + if (frame >= SPDIF_BLOCK_SAMPLES) + frame = 0; } - // Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer - // So pending uint32s / 2 = pending samples, and each sample is 2 bytes - size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get(); - size_t pending_samples = pending_uint32s / 2; - return pending_samples * 2; // 2 bytes per sample + + // Send any complete block that was just finished. + if (block_ptr >= block_end) { + esp_err_t err = this->send_block_(ticks_to_wait); + if (err != ESP_OK) { + save_state(); + report_out_params(); + return err; + } + block_ptr = block_buf; + ++block_count; + } + + save_state(); + report_out_params(); + return ESP_OK; } HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, size_t *bytes_consumed) { - const uint8_t *pcm_data = src; - const uint8_t *pcm_end = src + size; - uint32_t block_count = 0; + if (size > 0) { + // Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block. + this->block_buf_is_silence_block_ = false; + } + switch (this->bytes_per_sample_) { + case 2: + return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 3: + return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + case 4: + return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed); + default: + return ESP_ERR_INVALID_STATE; + } +} - while (pcm_data < pcm_end) { - // Check if there's a pending complete block from a previous failed send - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; - } - ++block_count; +template esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) { + // If a complete block is already pending (from a previous failed send), emit just that block. + // Otherwise pad the partial block with silence (or generate a full silence block if empty) and + // send. Always emits exactly one block on success. + if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get()); + // Continuous-silence idle case: a full silence block is byte-identical every time for the + // active channel status, so when the buffer already holds one, re-send it as-is. + if (was_empty && this->block_buf_is_silence_block_) { + return this->send_block_(ticks_to_wait); } - - // Encode one 16-bit sample - this->encode_sample_(pcm_data); - pcm_data += 2; - } - - // Send any complete block that was just finished - if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - esp_err_t err = this->send_block_(ticks_to_wait); - if (err != ESP_OK) { - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = pcm_data - src; - } - return err; + // Pad with silence frames at the configured width. + while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { + this->encode_silence_frame_(); } - ++block_count; + // The buffer is a reusable full-silence block only if it was built entirely from silence; a + // partial real-audio block padded out with silence is not. + this->block_buf_is_silence_block_ = was_empty; } - - if (blocks_sent != nullptr) { - *blocks_sent = block_count; - } - if (bytes_consumed != nullptr) { - *bytes_consumed = size; - } - return ESP_OK; + return this->send_block_(ticks_to_wait); } esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) { - // If a complete block is already pending (from a previous failed send), emit just that block. - // Otherwise pad the partial block with silence (or generate a full silence block if empty) - // and send. Always emits exactly one block on success. - if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - static const uint8_t SILENCE[2] = {0, 0}; - while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) { - this->encode_sample_(SILENCE); - } + switch (this->bytes_per_sample_) { + case 2: + return this->flush_with_silence_typed_<2>(ticks_to_wait); + case 3: + return this->flush_with_silence_typed_<3>(ticks_to_wait); + case 4: + return this->flush_with_silence_typed_<4>(ticks_to_wait); + default: + return ESP_ERR_INVALID_STATE; } - return this->send_block_(ticks_to_wait); } } // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.h b/esphome/components/i2s_audio/speaker/spdif_encoder.h index 8c5e068841..9e23a858f7 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.h +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.h @@ -24,8 +24,6 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768 // I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo) static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames -// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels) -static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes /// Callback signature for block completion (raw function pointer for minimal overhead) /// @param user_ctx User context pointer passed during callback registration @@ -64,8 +62,16 @@ class SPDIFEncoder { /// @brief Check if currently in preload mode bool is_preload_mode() const { return this->preload_mode_; } + /// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire). + /// Must be called before write() if input width changes from the default (16-bit). Triggers a + /// channel-status rebuild to reflect the new word length. + void set_bytes_per_sample(uint8_t bytes_per_sample); + + /// @brief Get the configured input PCM width in bytes per sample + uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; } + /// @brief Convert PCM audio data to SPDIF BMC encoded data - /// @param src Source PCM audio data (16-bit stereo) + /// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample) /// @param size Size of source data in bytes /// @param ticks_to_wait Timeout for blocking writes /// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent @@ -74,17 +80,6 @@ class SPDIFEncoder { esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr, size_t *bytes_consumed = nullptr); - /// @brief Get the number of PCM bytes currently pending in the partial block buffer - /// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1) - size_t get_pending_pcm_bytes() const; - - /// @brief Get the number of PCM frames currently pending in the partial block buffer - /// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1) - uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; } - - /// @brief Check if there is a partial block pending - bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); } - /// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send, /// or send a full silence block if nothing is pending. Always produces exactly one block on success. /// @param ticks_to_wait Timeout for blocking writes @@ -95,7 +90,7 @@ class SPDIFEncoder { void reset(); /// @brief Set the sample rate for Channel Status Block encoding - /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000) + /// @param sample_rate Sample rate in Hz (e.g., 44100, 48000) /// Call this before writing audio data to ensure correct channel status. void set_sample_rate(uint32_t sample_rate); @@ -103,8 +98,19 @@ class SPDIFEncoder { uint32_t get_sample_rate() const { return this->sample_rate_; } protected: - /// @brief Encode a single 16-bit PCM sample into the current block position - HOT void encode_sample_(const uint8_t *pcm_sample); + /// @brief Encode a single stereo silence frame at the current block position. + /// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the + /// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers. + template void encode_silence_frame_(); + + /// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_. + template + HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent, + size_t *bytes_consumed); + + /// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width + /// (or builds a full silence block when nothing is pending) and sends it. Always emits one block. + template esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait); /// @brief Send the completed block via the appropriate callback esp_err_t send_block_(TickType_t ticks_to_wait); @@ -112,15 +118,6 @@ class SPDIFEncoder { /// @brief Build the channel status block from current configuration void build_channel_status_(); - /// @brief Get the channel status bit for a specific frame - /// @param frame Frame number (0-191) - /// @return The C bit value for this frame - ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const { - // Channel status is 192 bits transmitted over 192 frames - // Bit N is transmitted in frame N, LSB-first within each byte - return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1; - } - // Member ordering optimized to minimize padding (largest alignment first) // 4-byte aligned members (pointers and uint32_t) @@ -133,9 +130,13 @@ class SPDIFEncoder { uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding // 1-byte aligned members (grouped together to avoid internal padding) - uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block - bool is_left_channel_{true}; // Alternates L/R for stereo samples - bool preload_mode_{false}; // Whether to use preload callback vs write callback + uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire. + uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block + bool preload_mode_{false}; // Whether to use preload callback vs write callback + // True when spdif_block_buf_ currently holds a complete full-silence block valid for the active + // channel status. A full silence block is deterministic for a given sample rate and word length, + // so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding. + bool block_buf_is_silence_block_{false}; // Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames) // Placed last since std::array has 1-byte alignment 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/iaqcore/sensor.py b/esphome/components/iaqcore/sensor.py index d3306fd0f8..1b905e4c63 100644 --- a/esphome/components/iaqcore/sensor.py +++ b/esphome/components/iaqcore/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_PARTS_PER_BILLION, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@yozik04"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index b1d332c1e5..64f87c167c 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -31,6 +31,7 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.final_validate import full_config +from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -91,7 +92,7 @@ CONF_INVERT_DISPLAY = "invert_display" CONF_PIXEL_MODE = "pixel_mode" -def cmd(c, *args): +def cmd(c: int, *args: int) -> list[int]: """ Create a command sequence :param c: The command (8 bit) @@ -101,7 +102,7 @@ def cmd(c, *args): return [c, len(args)] + list(args) -def map_sequence(value): +def map_sequence(value: list[int]) -> list[int]: """ An initialisation sequence is a literal array of data bytes. The format is a repeated sequence of [CMD, ] @@ -111,7 +112,7 @@ def map_sequence(value): return cmd(*value) -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if ( config.get(CONF_COLOR_PALETTE) == "IMAGE_ADAPTIVE" and CONF_COLOR_PALETTE_IMAGES not in config @@ -196,7 +197,7 @@ CONFIG_SCHEMA = cv.All( ) -def final_validate(config): +def final_validate(config: ConfigType) -> None: global_config = full_config.get() # Ideally would calculate buffer size here, but that info is not available on the Python side needs_buffer = ( @@ -218,7 +219,7 @@ def final_validate(config): FINAL_VALIDATE_SCHEMA = final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: LOGGER.warning( "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." ) @@ -278,7 +279,7 @@ async def to_code(config): cg.add(var.set_buffer_color_mode(ILI9XXXColorMode.BITS_8_INDEXED)) from PIL import Image - def load_image(filename): + def load_image(filename: str) -> Image.Image: path = CORE.relative_config_path(filename) try: return Image.open(path) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 365554f7d2..eaee31a1c7 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,38 +1,34 @@ 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_FILES, CONF_ID, - CONF_PATH, - CONF_RAW_DATA_ID, - CONF_RESIZE, - CONF_SOURCE, + CONF_PLATFORM, CONF_TYPE, - CONF_URL, ) -from esphome.core import CORE, HexInt +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") @@ -59,6 +55,9 @@ TRANSPARENCY_TYPES = ( CONF_ALPHA_CHANNEL, ) +# Shared validator for the image platform schemas and `_drop_incompatible_byte_order`. +validate_byte_order = cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True) + def get_image_type_enum(type): return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}") @@ -135,17 +134,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,123 +326,18 @@ 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 - with open(file, "rb") as f: + with Path(file).open("rb") as f: 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 +398,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 +412,308 @@ 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) + + +# --------------------------------------------------------------------------- +# `defaults:`/`files:` expansion: a `platform:` entry merges shared `defaults:` +# into every `files:` entry; the platform's CONFIG_SCHEMA validates each. +# Permanent, unlike the legacy migration below. +# --------------------------------------------------------------------------- + + +def _drop_incompatible_byte_order( + merged: dict, explicit: dict, *, index: int | None = None +) -> dict: + """Drop `byte_order` when the resolved type doesn't support it, unless written directly on `explicit`. + + With `index`, inherited values are validated before being dropped (the legacy flattener always drops). + """ + if CONF_BYTE_ORDER in explicit: + return merged + 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() + ): + if index is not None: + try: + validate_byte_order(merged[CONF_BYTE_ORDER]) + except cv.Invalid as exc: + exc.prepend([index]) + raise + del merged[CONF_BYTE_ORDER] + return merged + + +def _expand_platform_entry(index: int, entry: dict) -> list[dict]: + if CONF_FILES not in entry: + if CONF_DEFAULTS in entry: + raise cv.Invalid( + f"'{CONF_DEFAULTS}' may only be used together with '{CONF_FILES}'", + path=[index], + ) + return [entry] + + extra_keys = set(entry) - {CONF_PLATFORM, CONF_DEFAULTS, CONF_FILES} + if extra_keys: + raise cv.Invalid( + f"'{CONF_FILES}' cannot be combined with " + f"{', '.join(sorted(extra_keys))} on the same entry", + path=[index], + ) + + files = entry[CONF_FILES] + if files is None: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + if not isinstance(files, list): + raise cv.Invalid(f"'{CONF_FILES}' must be a list", path=[index]) + if not files: + raise cv.Invalid(f"'{CONF_FILES}' must not be empty", path=[index]) + + defaults = entry.get(CONF_DEFAULTS, {}) + if defaults is None: + defaults = {} + if not isinstance(defaults, dict): + raise cv.Invalid(f"'{CONF_DEFAULTS}' must be a mapping", path=[index]) + # Neither `id:` nor `platform:` makes sense inside `defaults:`. + for disallowed in (CONF_ID, CONF_PLATFORM): + if disallowed in defaults: + raise cv.Invalid( + f"'{disallowed}' is not allowed inside '{CONF_DEFAULTS}'", + path=[index], + ) + + from esphome import yaml_util + + platform = entry[CONF_PLATFORM] + result: list[dict] = [] + for file_entry in files: + if not isinstance(file_entry, dict): + raise cv.Invalid( + f"each entry in '{CONF_FILES}' must be a mapping", path=[index] + ) + # The platform is chosen by the entry's own `platform:` key, not per file. + if CONF_PLATFORM in file_entry: + raise cv.Invalid( + f"'{CONF_PLATFORM}' is not allowed inside '{CONF_FILES}'", + path=[index], + ) + # Keep the `files:` item's source range so whole-entry errors anchor there; + # `make_data_base` needs a real ESPHomeDataBase, so skip it for plain dicts. + source = ( + file_entry if isinstance(file_entry, yaml_util.ESPHomeDataBase) else None + ) + merged = yaml_util.make_data_base( + {CONF_PLATFORM: platform, **defaults, **file_entry}, source + ) + result.append(_drop_incompatible_byte_order(merged, file_entry, index=index)) + return result + + +def expand_platform_config(config: list) -> list: + """Expand `defaults:`/`files:` entries; the platform's own CONFIG_SCHEMA validates each result.""" + result = [] + for i, entry in enumerate(config): + if isinstance(entry, dict) and CONF_PLATFORM in entry: + result.extend(_expand_platform_entry(i, entry)) + else: + result.append(entry) + return result + + +EXPAND_PLATFORM_CONFIG = expand_platform_config + +# --------------------- end defaults/files expansion ------------------------- + + +# --------------------------------------------------------------------------- +# 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): + # Exclude `files:` entries -- the list branch would otherwise silently + # migrate them to `platform: file` instead of raising the missing-platform error. + return bool(config) and all( + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config + ) + if not isinstance(config, dict) or CONF_PLATFORM in config or CONF_FILES in config: + # `platform:`/`files:` dicts are new-format (left for list-wrapping + + # expansion); the legacy flattener has no `files:` branch and would drop them. + 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} + # Always drop, matching the pre-platform behavior -- see `_drop_incompatible_byte_order`. + result.append(_drop_incompatible_byte_order(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_base/__init__.py b/esphome/components/improv_base/__init__.py index e175aa2220..412d143a48 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -1,4 +1,5 @@ import re +from typing import Any import esphome.codegen as cg import esphome.config_validation as cv @@ -13,7 +14,7 @@ CONF_NEXT_URL = "next_url" VALID_SUBSTITUTIONS = ["esphome_version", "ip_address", "device_name"] -def validate_next_url(value): +def validate_next_url(value: Any) -> str: value = cv.url(value) test = r"{{(?!" + r"\b|".join(VALID_SUBSTITUTIONS) + r"\b)(\w+)}}" result = re.search(test, value) @@ -31,15 +32,15 @@ IMPROV_SCHEMA = cv.Schema( ) -def _process_next_url(url: str): +def _process_next_url(url: str) -> str: if "{{esphome_version}}" in url: url = url.replace("{{esphome_version}}", __version__) return url -async def setup_improv_core(var: MockObj, config: ConfigType, component: str): +async def setup_improv_core(var: MockObj, config: ConfigType, component: str) -> None: if next_url := config.get(CONF_NEXT_URL): cg.add(var.set_next_url(_process_next_url(next_url))) cg.add_define(f"USE_{component.upper()}_NEXT_URL") - cg.add_library("improv/Improv", "1.2.4") + cg.add_library("improv/Improv", "1.2.7") diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index fa1b855d6c..1babeb5b5a 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -4,10 +4,13 @@ #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/log.h" namespace esphome::improv_base { #if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +static const char *const TAG = "improv_base"; + static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1; static constexpr const char IP_ADDRESS_PLACEHOLDER[] = "{{ip_address}}"; @@ -62,6 +65,21 @@ size_t ImprovBase::get_formatted_next_url_(char *buffer, size_t buffer_size) { *out = '\0'; return out - buffer; } + +void ImprovBase::add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len) { + // The builder rejects strings above 254 bytes, so anything longer than this + // buffer could never be sent anyway + char url_buffer[256]; + size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer)); + if (len == 0) { + return; + } + // max_len is the transport's budget for this entry; skipping an over-long URL + // here keeps the rest of the response sendable instead of oversizing the frame + if (len > max_len || !builder.add_string(url_buffer, len)) { + ESP_LOGW(TAG, "Next URL too long; skipping"); + } +} #endif } // namespace esphome::improv_base diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index 9dded85a46..352bb75d5f 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,6 +3,10 @@ #include #include "esphome/core/defines.h" +#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#include +#endif + namespace esphome::improv_base { class ImprovBase { @@ -15,6 +19,8 @@ class ImprovBase { #if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) /// Format next_url_ into buffer, replacing placeholders. Returns length written. size_t get_formatted_next_url_(char *buffer, size_t buffer_size); + /// Append the formatted next_url to the RPC response, warning if it does not fit. + void add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len); const char *next_url_{nullptr}; #endif }; diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 4266f5b78b..11e9f1ea62 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -1,11 +1,18 @@ import esphome.codegen as cg -from esphome.components import improv_base +from esphome.components import improv_base, uart from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant from esphome.components.logger import USB_CDC import esphome.config_validation as cv -from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_LOGGER +from esphome.const import ( + CONF_BAUD_RATE, + CONF_HARDWARE_UART, + CONF_ID, + CONF_LOGGER, + CONF_UART_ID, +) from esphome.core import CORE import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["improv_base"] CODEOWNERS = ["@esphome/core"] @@ -16,13 +23,35 @@ improv_serial_ns = cg.esphome_ns.namespace("improv_serial") ImprovSerialComponent = improv_serial_ns.class_("ImprovSerialComponent", cg.Component) CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(ImprovSerialComponent)}) + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ImprovSerialComponent), + # YAML only: rewiring Improv onto another UART is not a knob for a + # visual editor and the device builder must not expose it + cv.Optional(CONF_UART_ID, visibility=cv.Visibility.YAML_ONLY): cv.use_id( + uart.UARTComponent + ), + } + ) .extend(improv_base.IMPROV_SCHEMA) .extend(cv.COMPONENT_SCHEMA) ) -def validate_logger(config): +_UART_FINAL_VALIDATE = uart.final_validate_device_schema( + "improv_serial", require_tx=True, require_rx=True +) + + +def validate_transport(config: ConfigType) -> None: + if CONF_UART_ID in config: + # A dedicated UART bus is used; the logger's serial settings are irrelevant, + # but the bus itself must be bidirectional and not claimed by another device + _UART_FINAL_VALIDATE(config) + return + # The host logger has no serial port for Improv to share + if CORE.is_host: + raise cv.Invalid("improv_serial on the host platform requires uart_id") logger_conf = fv.full_config.get()[CONF_LOGGER] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") @@ -33,14 +62,16 @@ def validate_logger(config): raise cv.Invalid( "improv_serial does not support the selected logger hardware_uart" ) - return config -FINAL_VALIDATE_SCHEMA = validate_logger +FINAL_VALIDATE_SCHEMA = validate_transport -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await improv_base.setup_improv_core(var, config, "improv_serial") cg.add_define("USE_IMPROV_SERIAL") + if (uart_id := config.get(CONF_UART_ID)) is not None: + cg.add(var.set_uart(await cg.get_variable(uart_id))) + cg.add_define("USE_IMPROV_SERIAL_UART") diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 18d0b44701..0fb18e9b0d 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -7,6 +7,9 @@ #include "esphome/core/version.h" #include "esphome/components/logger/logger.h" +#include "esphome/components/wifi/scan_list.h" + +#include namespace esphome::improv_serial { @@ -14,35 +17,42 @@ static const char *const TAG = "improv_serial"; void ImprovSerialComponent::setup() { global_improv_serial_component = this; -#ifdef USE_ESP32 +#ifdef USE_IMPROV_SERIAL_UART + // Transport is a dedicated UART bus set via set_uart() in generated code +#elif defined(USE_ESP32) this->uart_num_ = logger::global_logger->get_uart_num(); + this->uart_selection_ = logger::global_logger->get_uart(); #elif defined(USE_ARDUINO) this->hw_serial_ = logger::global_logger->get_hw_serial(); #endif if (wifi::global_wifi_component->has_sta()) { this->state_ = improv::STATE_PROVISIONED; - } else { + } else if (!wifi::global_wifi_component->is_disabled()) { + // Respect Wi-Fi's disabled state; forcing a scan while disabled throws + // the wifi component into an invalid state from which it cannot recover. wifi::global_wifi_component->start_scanning(); } } void ImprovSerialComponent::loop() { - if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { + const uint32_t now = App.get_loop_component_start_time(); + if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) { this->last_read_byte_ = 0; this->rx_buffer_.clear(); ESP_LOGV(TAG, "Timeout"); } - auto byte = this->read_byte_(); - while (byte.has_value()) { + while (true) { + auto byte = this->read_byte_(); + if (!byte.has_value()) + break; if (this->parse_improv_serial_byte_(byte.value())) { - this->last_read_byte_ = millis(); + this->last_read_byte_ = now; } else { this->last_read_byte_ = 0; this->rx_buffer_.clear(); } - byte = this->read_byte_(); } if (this->state_ == improv::STATE_PROVISIONING) { @@ -53,63 +63,13 @@ void ImprovSerialComponent::loop() { this->cancel_timeout("wifi-connect-timeout"); this->set_state_(improv::STATE_PROVISIONED); - std::vector url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS); - this->send_response_(url); + this->send_settings_response_(improv::WIFI_SETTINGS); } } } void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); } -optional ImprovSerialComponent::read_byte_() { - optional byte; - uint8_t data = 0; -#ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { - case logger::UART_SELECTION_UART0: - case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) - case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && - // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 - if (this->uart_num_ >= 0) { - size_t available; - uart_get_buffered_data_len(this->uart_num_, &available); - if (available) { - uart_read_bytes(this->uart_num_, &data, 1, 0); - byte = data; - } - } - break; -#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) - case logger::UART_SELECTION_USB_CDC: - if (esp_usb_console_available_for_read()) { - esp_usb_console_read_buf((char *) &data, 1); - byte = data; - } - break; -#endif // USE_LOGGER_USB_CDC -#ifdef USE_LOGGER_USB_SERIAL_JTAG - case logger::UART_SELECTION_USB_SERIAL_JTAG: { - if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { - byte = data; - } - break; - } -#endif // USE_LOGGER_USB_SERIAL_JTAG - default: - break; - } -#elif defined(USE_ARDUINO) - if (this->hw_serial_->available()) { - this->hw_serial_->readBytes(&data, 1); - byte = data; - } -#endif - return byte; -} - void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) { // First, set length field this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1; @@ -132,12 +92,17 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) } this->tx_header_[TX_CHECKSUM_IDX] = checksum; -#ifdef USE_ESP32 - switch (logger::global_logger->get_uart()) { +#ifdef USE_IMPROV_SERIAL_UART + this->uart_->write_array(this->tx_header_, header_tx_len); + if (there_is_data) { + this->uart_->write_array(data, size); + this->uart_->write_array(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline + } +#elif defined(USE_ESP32) + switch (this->uart_selection_) { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: -#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) +#if defined(USE_ESP32_VARIANT_ESP32) case logger::UART_SELECTION_UART2: #endif uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); @@ -178,16 +143,11 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) #endif } -std::vector ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) { - std::vector urls; +void ImprovSerialComponent::send_settings_response_(improv::Command command) { + std::array buf; + improv::RpcResponseBuilder builder(buf, command); #ifdef USE_IMPROV_SERIAL_NEXT_URL - { - char url_buffer[384]; - size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer)); - if (len > 0) { - urls.emplace_back(url_buffer, len); - } - } + this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif #ifdef USE_WEBSERVER for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { @@ -196,25 +156,63 @@ std::vector ImprovSerialComponent::build_rpc_settings_response_(improv: ip.str_to(ip_buf); // "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54 char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1]; - snprintf(webserver_url, sizeof(webserver_url), "http://%s:%u", ip_buf, USE_WEBSERVER_PORT); - urls.emplace_back(webserver_url); + // buf_append_printf keeps the format string in flash on ESP8266 + size_t len = + buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT); + if (!builder.add_string(webserver_url, len)) { + ESP_LOGW(TAG, "Response full; URL dropped"); + } break; } } #endif - std::vector data = improv::build_rpc_response(command, urls, false); - return data; + this->send_response_(builder.finish(false)); } -std::vector ImprovSerialComponent::build_version_info_() { +void ImprovSerialComponent::send_version_info_() { +// Entry cost per field is sizeof(lit): a length byte plus the string #ifdef ESPHOME_PROJECT_NAME - std::vector infos = {ESPHOME_PROJECT_NAME, ESPHOME_PROJECT_VERSION, ESPHOME_VARIANT, App.get_name()}; + static constexpr size_t INFO_ENTRIES_LEN = + sizeof(ESPHOME_PROJECT_NAME) + sizeof(ESPHOME_PROJECT_VERSION) + sizeof(ESPHOME_VARIANT); #else - std::vector infos = {"ESPHome", ESPHOME_VERSION, ESPHOME_VARIANT, App.get_name()}; + static constexpr size_t INFO_ENTRIES_LEN = sizeof("ESPHome") + sizeof(ESPHOME_VERSION) + sizeof(ESPHOME_VARIANT); #endif - std::vector data = improv::build_rpc_response(improv::GET_DEVICE_INFO, infos, false); - return data; -}; + static_assert(INFO_ENTRIES_LEN < MAX_SERIAL_PAYLOAD, + "esphome project name and version too long for the improv_serial device info frame"); + std::array buf; + improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO); +#ifdef USE_ESP8266 + // Keep each literal in flash and copy it through an exact size stack buffer, + // so a long project name or version can never be truncated +#define IMPROV_ADD_INFO(lit) \ + do { \ + static const char progmem_str[] PROGMEM = lit; \ + char tmp[sizeof(lit)]; \ + progmem_memcpy(tmp, progmem_str, sizeof(lit)); \ + builder.add_string(tmp, sizeof(lit) - 1); \ + } while (0) +#else + // Literals are directly flash mapped on all other platforms +#define IMPROV_ADD_INFO(lit) builder.add_string(lit, sizeof(lit) - 1) +#endif +#ifdef ESPHOME_PROJECT_NAME + IMPROV_ADD_INFO(ESPHOME_PROJECT_NAME); + IMPROV_ADD_INFO(ESPHOME_PROJECT_VERSION); +#else + IMPROV_ADD_INFO("ESPHome"); + IMPROV_ADD_INFO(ESPHOME_VERSION); +#endif + IMPROV_ADD_INFO(ESPHOME_VARIANT); +#undef IMPROV_ADD_INFO + // Only the device name length is unknown at compile time + const auto &name = App.get_name(); + if (INFO_ENTRIES_LEN + 1 + name.size() <= MAX_SERIAL_PAYLOAD) { + builder.add_string(name.c_str(), name.size()); + } else { + ESP_LOGW(TAG, "Response full; device name dropped"); + } + this->send_response_(builder.finish(false)); +} bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { size_t at = this->rx_buffer_.size(); @@ -233,6 +231,13 @@ bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) { switch (command.command) { case improv::WIFI_SETTINGS: { + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled, so we can't provision. Respond immediately + // instead of letting the client wait out its provisioning timeout. + ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); + return true; + } wifi::WiFiAP sta{}; sta.set_ssid(command.ssid.c_str()); sta.set_password(command.password.c_str()); @@ -248,48 +253,45 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command return true; } case improv::GET_CURRENT_STATE: + if (wifi::global_wifi_component->is_disabled()) { + // Wi-Fi is disabled; report the Improv "stopped" state so a client can tell + // the user that provisioning is unavailable. Reported transiently without + // disturbing our internal provisioning state machine, so a later `wifi.enable` + // still reports the correct state. + this->send_current_state_(improv::STATE_STOPPED); + return true; + } this->set_state_(this->state_); if (this->state_ == improv::STATE_PROVISIONED) { - std::vector url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE); - this->send_response_(url); + this->send_settings_response_(improv::GET_CURRENT_STATE); } return true; case improv::GET_DEVICE_INFO: { - std::vector info = this->build_version_info_(); - this->send_response_(info); + this->send_version_info_(); return true; } case improv::GET_WIFI_NETWORKS: { - std::vector networks; const auto &results = wifi::global_wifi_component->get_scan_result(); - for (auto &scan : results) { - if (scan.get_is_hidden()) + std::array buf; + for (const auto &scan : results) { + bool with_auth = false; + if (!wifi::should_show_scan_entry(results, scan, with_auth)) continue; - const char *ssid_cstr = scan.get_ssid().c_str(); - // Check if we've already sent this SSID - bool duplicate = false; - for (const auto &seen : networks) { - if (strcmp(seen.c_str(), ssid_cstr) == 0) { - duplicate = true; - break; - } - } - if (duplicate) - continue; - // Only allocate std::string after confirming it's not a duplicate - std::string ssid(ssid_cstr); // Send each ssid separately to avoid overflowing the buffer char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null - *int8_to_str(rssi_buf, scan.get_rssi()) = '\0'; - std::vector data = - improv::build_rpc_response(improv::GET_WIFI_NETWORKS, {ssid, rssi_buf, YESNO(scan.get_with_auth())}, false); - this->send_response_(data); - networks.push_back(std::move(ssid)); + char *rssi_end = int8_to_str(rssi_buf, scan.get_rssi()); + *rssi_end = '\0'; + improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS); + // SSID(32) + RSSI(4) + YESNO(3) entries always fit the payload + const auto &ssid = scan.get_ssid(); + builder.add_string(ssid.c_str(), ssid.size()); + builder.add_string(rssi_buf, rssi_end - rssi_buf); + builder.add_string(YESNO(with_auth)); + this->send_response_(builder.finish(false)); } // Send empty response to signify the end of the list. - std::vector data = - improv::build_rpc_response(improv::GET_WIFI_NETWORKS, std::vector{}, false); - this->send_response_(data); + improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS); + this->send_response_(builder.finish(false)); return true; } default: { @@ -302,6 +304,10 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command void ImprovSerialComponent::set_state_(improv::State state) { this->state_ = state; + this->send_current_state_(state); +} + +void ImprovSerialComponent::send_current_state_(improv::State state) { this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE; this->tx_header_[TX_DATA_IDX] = state; this->write_data_(); @@ -313,7 +319,14 @@ void ImprovSerialComponent::set_error_(improv::Error error) { this->write_data_(); } -void ImprovSerialComponent::send_response_(std::vector &response) { +void ImprovSerialComponent::send_response_(std::span response) { + // The serial frame length field is a single byte + if (response.size() > MAX_SERIAL_RESPONSE) { + ESP_LOGE(TAG, "Response too long"); + // Fail fast instead of leaving the client to wait out its timeout + this->set_error_(improv::ERROR_UNKNOWN); + return; + } this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE; this->write_data_(response.data(), response.size()); } diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 2f1d0136a4..692873bbb6 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -1,18 +1,21 @@ #pragma once #include "esphome/components/improv_base/improv_base.h" +#include "esphome/components/logger/logger.h" #include "esphome/components/wifi/wifi_component.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #ifdef USE_WIFI #include +#include #include -#ifdef USE_ESP32 +#ifdef USE_IMPROV_SERIAL_UART +#include "esphome/components/uart/uart_component.h" +#elif defined(USE_ESP32) #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ - defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32S3) +#ifdef USE_LOGGER_USB_SERIAL_JTAG #include #include #endif @@ -45,7 +48,23 @@ 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 { +// The serial frame length field is one byte +static constexpr size_t MAX_SERIAL_RESPONSE = 255; +// command + data length + trailing byte +static constexpr size_t RPC_RESPONSE_OVERHEAD = 3; +static constexpr size_t MAX_SERIAL_PAYLOAD = MAX_SERIAL_RESPONSE - RPC_RESPONSE_OVERHEAD; +#ifdef USE_WEBSERVER +// length byte + "http://" + IPv4 + ":" + port +static constexpr size_t WEBSERVER_URL_RESERVE = 1 + 7 + 15 + 1 + 5; +#else +static constexpr size_t WEBSERVER_URL_RESERVE = 0; +#endif +// Entry budget minus its own length byte +static constexpr size_t MAX_NEXT_URL_LEN = MAX_SERIAL_PAYLOAD - WEBSERVER_URL_RESERVE - 1; + +static_assert(MAX_SERIAL_RESPONSE <= improv::RPC_RESPONSE_MAX_SIZE, "builder buffer too small for the frame"); + +class ImprovSerialComponent final : public Component, public improv_base::ImprovBase { public: void setup() override; void loop() override; @@ -53,19 +72,73 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } +#ifdef USE_IMPROV_SERIAL_UART + void set_uart(uart::UARTComponent *uart) { this->uart_ = uart; } +#endif + protected: bool parse_improv_serial_byte_(uint8_t byte); bool parse_improv_payload_(improv::ImprovCommand &command); void set_state_(improv::State state); + void send_current_state_(improv::State state); void set_error_(improv::Error error); - void send_response_(std::vector &response); + void send_response_(std::span response); void on_wifi_connect_timeout_(); - std::vector build_rpc_settings_response_(improv::Command command); - std::vector build_version_info_(); + void send_settings_response_(improv::Command command); + void send_version_info_(); - optional read_byte_(); + ESPHOME_ALWAYS_INLINE optional read_byte_() { + optional byte; + uint8_t data = 0; +#ifdef USE_IMPROV_SERIAL_UART + if (this->uart_->available() && this->uart_->read_byte(&data)) { + byte = data; + } +#elif defined(USE_ESP32) + switch (this->uart_selection_) { + case logger::UART_SELECTION_UART0: + case logger::UART_SELECTION_UART1: +#if defined(USE_ESP32_VARIANT_ESP32) + case logger::UART_SELECTION_UART2: +#endif + if (this->uart_num_ >= 0) { + size_t available; + uart_get_buffered_data_len(this->uart_num_, &available); + if (available) { + uart_read_bytes(this->uart_num_, &data, 1, 0); + byte = data; + } + } + break; +#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) + case logger::UART_SELECTION_USB_CDC: + if (esp_usb_console_available_for_read()) { + esp_usb_console_read_buf((char *) &data, 1); + byte = data; + } + break; +#endif +#ifdef USE_LOGGER_USB_SERIAL_JTAG + case logger::UART_SELECTION_USB_SERIAL_JTAG: { + if (usb_serial_jtag_read_bytes((char *) &data, 1, 0)) { + byte = data; + } + break; + } +#endif + default: + break; + } +#elif defined(USE_ARDUINO) + if (this->hw_serial_->available()) { + this->hw_serial_->readBytes(&data, 1); + byte = data; + } +#endif + return byte; + } void write_data_(const uint8_t *data = nullptr, size_t size = 0); uint8_t tx_header_[TX_BUFFER_SIZE] = { @@ -83,8 +156,11 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase { '\n', }; -#ifdef USE_ESP32 +#ifdef USE_IMPROV_SERIAL_UART + uart::UARTComponent *uart_{nullptr}; +#elif defined(USE_ESP32) uart_port_t uart_num_; + logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0}; #elif defined(USE_ARDUINO) Stream *hw_serial_{nullptr}; #endif 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/ina219/sensor.py b/esphome/components/ina219/sensor.py index 621fd62e82..97482f81a0 100644 --- a/esphome/components/ina219/sensor.py +++ b/esphome/components/ina219/sensor.py @@ -18,6 +18,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -70,7 +71,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/ina226/sensor.py b/esphome/components/ina226/sensor.py index 2a7b3fc212..4fd98fbcd4 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import i2c, sensor import esphome.config_validation as cv @@ -18,6 +20,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -54,7 +57,7 @@ ADC_AVG_SAMPLES = { } -def validate_adc_time(value): +def validate_adc_time(value: Any) -> int: value = cv.positive_time_period_microseconds(value).total_microseconds return cv.enum(ADC_TIMES, int=True)(value) @@ -112,7 +115,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/ina260/sensor.py b/esphome/components/ina260/sensor.py index b98b4ce6cb..b7b94a248b 100644 --- a/esphome/components/ina260/sensor.py +++ b/esphome/components/ina260/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@mreditor97"] @@ -52,7 +53,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 15e2faba07..7bb589f0b1 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -1,3 +1,5 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import sensor from esphome.components.const import UNIT_AMPERE_HOUR @@ -26,6 +28,9 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.core import EnumValue +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] @@ -76,7 +81,7 @@ SENSOR_MODEL_OPTIONS = { } -def validate_model_config(config): +def validate_model_config(config: ConfigType) -> ConfigType: model = config[CONF_MODEL] for key in config: @@ -92,7 +97,7 @@ def validate_model_config(config): return config -def validate_adc_time(value): +def validate_adc_time(value: Any) -> EnumValue: value = cv.positive_time_period_microseconds(value).total_microseconds return cv.enum(ADC_TIMES, int=True)(value) @@ -198,7 +203,7 @@ INA2XX_SCHEMA = cv.Schema( ).extend(cv.polling_component_schema("60s")) -async def setup_ina2xx(var, config): +async def setup_ina2xx(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) cg.add(var.set_model(config[CONF_MODEL])) 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_i2c/sensor.py b/esphome/components/ina2xx_i2c/sensor.py index 1a470aa628..4bcbca8762 100644 --- a/esphome/components/ina2xx_i2c/sensor.py +++ b/esphome/components/ina2xx_i2c/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, ina2xx_base import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODEL +from esphome.types import ConfigType AUTO_LOAD = ["ina2xx_base"] CODEOWNERS = ["@latonita"] @@ -28,7 +29,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ina2xx_base.setup_ina2xx(var, config) await i2c.register_i2c_device(var, config) 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/ina2xx_spi/sensor.py b/esphome/components/ina2xx_spi/sensor.py index 3ebe2cac73..dc72dce7a9 100644 --- a/esphome/components/ina2xx_spi/sensor.py +++ b/esphome/components/ina2xx_spi/sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import ina2xx_base, spi import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODEL +from esphome.types import ConfigType AUTO_LOAD = ["ina2xx_base"] CODEOWNERS = ["@latonita"] @@ -27,7 +28,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await ina2xx_base.setup_ina2xx(var, config) await spi.register_spi_device(var, config) 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/ina3221/sensor.py b/esphome/components/ina3221/sensor.py index acf7d7cdf0..db8dad2f54 100644 --- a/esphome/components/ina3221/sensor.py +++ b/esphome/components/ina3221/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -74,7 +75,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index f8e77209b2..d04c82ea96 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -14,7 +14,7 @@ from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.core.entity_helpers import queue_entity_register, setup_entity from esphome.coroutine import CoroPriority -from esphome.types import ConfigType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] AUTO_LOAD = ["remote_base"] @@ -46,11 +46,11 @@ def infrared_schema(class_: type[cg.MockObjClass]) -> cv.Schema: @setup_entity("infrared") -async def setup_infrared_core_(var: cg.Pvariable, config: ConfigType) -> None: +async def setup_infrared_core_(var: cg.MockObj, config: ConfigType) -> None: """Set up core infrared configuration.""" -async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: +async def register_infrared(var: cg.MockObj, config: ConfigType) -> None: """Register an infrared device with the core.""" cg.add_define("USE_IR_RF") await cg.register_component(var, config) @@ -59,7 +59,7 @@ async def register_infrared(var: cg.Pvariable, config: ConfigType) -> None: CORE.register_platform_component("infrared", var) -async def new_infrared(config: ConfigType, *args) -> cg.Pvariable: +async def new_infrared(config: ConfigType, *args: SafeExpType) -> cg.MockObj: """Create a new Infrared instance. :param config: Configuration dictionary. diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 9b97995a96..5a909738c6 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -75,8 +75,6 @@ void Infrared::dump_config() { YESNO(this->traits_.get_supports_receiver())); } -InfraredCall Infrared::make_call() { return InfraredCall(this); } - void Infrared::control(const InfraredCall &call) { if (this->transmitter_ == nullptr) { ESP_LOGW(TAG, "No transmitter configured"); diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index 6d91c97cce..b6863e37ce 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -134,7 +134,7 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote const InfraredTraits &get_traits() const { return this->traits_; } /// Create a call object for transmitting - InfraredCall make_call(); + InfraredCall make_call() { return InfraredCall(this); } /// Get capability flags for this infrared instance uint32_t get_capability_flags() const; diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index 4df22aa9de..d360142bcb 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -1,8 +1,6 @@ #include "inkbird_ibsth1_mini.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 - namespace esphome::inkbird_ibsth1_mini { static const char *const TAG = "inkbird_ibsth1_mini"; @@ -15,7 +13,7 @@ void InkbirdIbstH1Mini::dump_config() { LOG_SENSOR(" ", "Battery Level", this->battery_level_); } -bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { +bool InkbirdIbstH1Mini::parse_device(const ble_device_base::ESPBTDevice &device) { // The below is based on my research and reverse engineering of a single device // It is entirely possible that some of that may be inaccurate or incomplete @@ -32,7 +30,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); return false; } - if (device.get_address_type() != BLE_ADDR_TYPE_PUBLIC) { + if (device.get_address_type() != ble_device_base::BLE_ADDR_TYPE_PUBLIC) { ESP_LOGVV(TAG, "parse_device(): address is not public"); return false; } @@ -46,7 +44,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic return false; } const auto &mnf_data = mnf_datas[0]; - if (mnf_data.uuid.get_uuid().len != ESP_UUID_LEN_16) { + if (mnf_data.uuid.type() != ble_device_base::ESPBTUUID::Type::UUID16) { ESP_LOGVV(TAG, "parse_device(): manufacturer data element is expected to have uuid of length 16"); return false; } @@ -71,7 +69,7 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic auto external_temperature = NAN; // Read bluetooth data into variable - auto measured_temperature = ((int16_t) mnf_data.uuid.get_uuid().uuid.uuid16) / 100.0f; + auto measured_temperature = ((int16_t) mnf_data.uuid.uuid16()) / 100.0f; // Set temperature or external_temperature based on which sensor is in use if (mnf_data.data[2] == 0) { @@ -104,5 +102,3 @@ bool InkbirdIbstH1Mini::parse_device(const esp32_ble_tracker::ESPBTDevice &devic } } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h index 37e50943f3..726ea8c5ea 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.h @@ -2,17 +2,15 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" -#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" - -#ifdef USE_ESP32 +#include "esphome/components/ble_device_base/ble_device.h" namespace esphome::inkbird_ibsth1_mini { -class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDeviceListener { +class InkbirdIbstH1Mini final : public Component, public ble_device_base::ESPBTDeviceListener { public: void set_address(uint64_t address) { address_ = address; } - bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + bool parse_device(const ble_device_base::ESPBTDevice &device) override; void dump_config() override; void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } @@ -29,5 +27,3 @@ class InkbirdIbstH1Mini : public Component, public esp32_ble_tracker::ESPBTDevic }; } // namespace esphome::inkbird_ibsth1_mini - -#endif diff --git a/esphome/components/inkbird_ibsth1_mini/sensor.py b/esphome/components/inkbird_ibsth1_mini/sensor.py index b446c9f1e2..84a207020e 100644 --- a/esphome/components/inkbird_ibsth1_mini/sensor.py +++ b/esphome/components/inkbird_ibsth1_mini/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import esp32_ble_tracker, sensor +from esphome.components import ble_device_base, sensor import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_LEVEL, @@ -16,16 +16,18 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PERCENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@fkirill"] -DEPENDENCIES = ["esp32_ble_tracker"] +AUTO_LOAD = ["ble_device_base"] inkbird_ibsth1_mini_ns = cg.esphome_ns.namespace("inkbird_ibsth1_mini") InkbirdIbstH1Mini = inkbird_ibsth1_mini_ns.class_( - "InkbirdIbstH1Mini", esp32_ble_tracker.ESPBTDeviceListener, cg.Component + "InkbirdIbstH1Mini", ble_device_base.ESPBTDeviceListener, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("inkbird_ibsth1_mini"), cv.Schema( { cv.GenerateID(): cv.declare_id(InkbirdIbstH1Mini), @@ -57,15 +59,15 @@ CONFIG_SCHEMA = ( ), } ) - .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await esp32_ble_tracker.register_ble_device(var, config) + await ble_device_base.register_ble_device(var, config) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 47c8c898e5..350a0c1652 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -19,6 +19,7 @@ from esphome.const import ( PLATFORM_ESP32, ) import esphome.final_validate as fv +from esphome.types import ConfigType from .const import INKPLATE_10_CUSTOM_WAVEFORMS, WAVEFORMS @@ -68,7 +69,7 @@ MODELS = { CONF_CUSTOM_WAVEFORM = "custom_waveform" -def _validate_custom_waveform(config): +def _validate_custom_waveform(config: ConfigType) -> ConfigType: if CONF_CUSTOM_WAVEFORM in config and config[CONF_MODEL] != "inkplate_10": raise cv.Invalid("Custom waveforms are only supported on the Inkplate 10") return config @@ -146,19 +147,18 @@ CONFIG_SCHEMA = cv.All( ) -def _validate_cpu_frequency(config): +def _validate_cpu_frequency(config: ConfigType) -> None: esp32_config = fv.full_config.get()[PLATFORM_ESP32] if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": raise cv.Invalid( "Inkplate requires 240MHz CPU frequency (set in esp32 component)" ) - return config FINAL_VALIDATE_SCHEMA = _validate_cpu_frequency -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 39110ca83b..2e837fb614 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -319,7 +319,7 @@ void Inkplate::fill(Color color) { memset(this->partial_buffer_, fill, this->get_buffer_length_()); } - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display() { 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/integration/sensor.py b/esphome/components/integration/sensor.py index 8d784df672..82e8ba8df8 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( @@ -39,14 +42,14 @@ CONF_TIME_UNIT = "time_unit" CONF_INTEGRATION_METHOD = "integration_method" -def inherit_unit_of_measurement(uom, config): +def inherit_unit_of_measurement(uom: str, config: ConfigType) -> str: suffix = config[CONF_TIME_UNIT] if uom.endswith("/" + suffix): return uom[0 : -len("/" + suffix)] return uom + suffix -def inherit_accuracy_decimals(decimals, config): +def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int: return decimals + 2 @@ -90,7 +93,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -113,7 +116,12 @@ async def to_code(config): ), synchronous=True, ) -async def sensor_integration_reset_to_code(config, action_id, template_arg, args): +async def sensor_integration_reset_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -130,7 +138,12 @@ async def sensor_integration_reset_to_code(config, action_id, template_arg, args ), synchronous=True, ) -async def sensor_integration_set_value_to_code(config, action_id, template_arg, args): +async def sensor_integration_set_value_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 4810e8478d..90831cf211 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -3,9 +3,15 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" +// Every ESP32 variant except the original one exposes the on-chip sensor through +// the IDF temperature_sensor driver (the original uses the legacy temprature_sens_read). +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) +#include "driver/temperature_sensor.h" +#endif + 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; @@ -13,6 +19,11 @@ class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent void dump_config() override; void update() override; + +#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32) + protected: + temperature_sensor_handle_t tsens_{nullptr}; +#endif }; } // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index b7332ee81f..91f47d831f 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.bk72xx"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 09121fa9c9..2c6fda2af4 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -3,27 +3,20 @@ #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 { -static const char *const TAG = "internal_temperature.esp32"; - -#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) -static temperature_sensor_handle_t tsensNew = NULL; -#endif // USE_ESP32_VARIANT +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; @@ -33,11 +26,8 @@ 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) - esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); +#elif SOC_TEMP_SENSOR_SUPPORTED + esp_err_t result = temperature_sensor_get_celsius(this->tsens_, &temperature); success = (result == ESP_OK); if (!success) { ESP_LOGE(TAG, "Reading failed (%d)", result); @@ -55,19 +45,17 @@ 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, &tsensNew); + esp_err_t result = temperature_sensor_install(&tsens_config, &this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Install failed (%d)", result); this->mark_failed(); return; } - result = temperature_sensor_enable(tsensNew); + result = temperature_sensor_enable(this->tsens_); if (result != ESP_OK) { ESP_LOGE(TAG, "Enabling failed (%d)", result); this->mark_failed(); diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp new file mode 100644 index 0000000000..c4ab33b0a5 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -0,0 +1,90 @@ +#ifdef USE_RP2 + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature"; + +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + +void InternalTemperatureSensor::update() { + float temperature = NAN; + bool success = false; + + temperature = read_internal_temperature(); + success = (temperature != 0.0f); + + if (success && std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_RP2 diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2040.cpp deleted file mode 100644 index 66dee9faf7..0000000000 --- a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#ifdef USE_RP2040 - -#include "esphome/core/log.h" -#include "internal_temperature.h" - -#include "Arduino.h" - -namespace esphome::internal_temperature { - -static const char *const TAG = "internal_temperature.rp2040"; - -void InternalTemperatureSensor::update() { - float temperature = NAN; - bool success = false; - - temperature = analogReadTemp(); - success = (temperature != 0.0f); - - if (success && std::isfinite(temperature)) { - this->publish_state(temperature); - } else { - ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); - if (!this->has_state()) { - this->publish_state(NAN); - } - } -} - -} // namespace esphome::internal_temperature - -#endif // USE_RP2040 diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index be72ab6f51..50c597f6f1 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.zephyr"; +static const char *const TAG = "internal_temperature"; static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 02730b6862..d3101f4a7c 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -10,12 +10,13 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, PlatformFramework, ) from esphome.core import CORE +from esphome.types import ConfigType internal_temperature_ns = cg.esphome_ns.namespace("internal_temperature") InternalTemperatureSensor = internal_temperature_ns.class_( @@ -34,7 +35,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on( [ PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_NRF52, PLATFORM_LN882X, @@ -43,7 +44,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) @@ -58,7 +59,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/__init__.py b/esphome/components/interval/__init__.py index ac9219ff6a..11c3e15b0d 100644 --- a/esphome/components/interval/__init__.py +++ b/esphome/components/interval/__init__.py @@ -2,6 +2,7 @@ from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTERVAL, CONF_STARTUP_DELAY +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] interval_ns = cg.esphome_ns.namespace("interval") @@ -22,7 +23,7 @@ CONFIG_SCHEMA = automation.validate_automation( ) -async def to_code(config): +async def to_code(config: list[ConfigType]) -> None: for conf in config: var = cg.new_Pvariable(conf[CONF_ID]) await cg.register_component(var, conf) 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..57bf86c4c6 --- /dev/null +++ b/esphome/components/it8951/display.py @@ -0,0 +1,444 @@ +""" +ESPHome configuration for the IT8951 e-paper controller. +""" + +from collections.abc import Callable +from typing import Any + +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.core import ID +from esphome.cpp_generator import MockObj, RawExpression, TemplateArgsType +from esphome.final_validate import full_config +from esphome.types import ConfigType + +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: Any) -> None: + name = name.upper() + self.name = name + self.defaults = defaults + IT8951Model.models[name] = self + + def get_default(self, key: str, fallback: Any = None) -> Any: + return self.defaults.get(key, fallback) + + def get_dimensions(self, config: ConfigType) -> 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: IT8951Model, key: str, schema: Callable[[Any], Any] +) -> tuple[cv.Optional | cv.Required, Callable[[Any], Any]]: + 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: ConfigType) -> cv.Schema: + 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: ConfigType) -> ConfigType: + 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: ConfigType) -> None: + # 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 + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + 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: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + 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/jsn_sr04t/sensor.py b/esphome/components/jsn_sr04t/sensor.py index 214724aa3f..0c4187b823 100644 --- a/esphome/components/jsn_sr04t/sensor.py +++ b/esphome/components/jsn_sr04t/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_METER, ) +from esphome.types import ConfigType CODEOWNERS = ["@Mafus1"] DEPENDENCIES = ["uart"] @@ -49,7 +50,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await uart.register_uart_device(var, config) diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index 28fdcd41ef..af7eb7e733 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] json_ns = cg.esphome_ns.namespace("json") @@ -11,12 +12,12 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.BUS) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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..6465012897 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -21,7 +21,9 @@ from esphome.const import ( UNIT_EMPTY, UNIT_KELVIN, UNIT_KILOWATT, + UNIT_LITRE_PER_HOUR, ) +from esphome.types import ConfigType CODEOWNERS = ["@cfeenstra1024"] DEPENDENCIES = ["uart"] @@ -37,7 +39,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 = ( @@ -105,7 +106,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index 1f4519df2d..bf47b6df88 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,8 +15,9 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TRIGGER_ID, ) +from esphome.core import ID from esphome.cpp_generator import MockObj, literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@ssieb"] @@ -90,7 +91,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) for source_conf in config.get(CONF_SOURCE_ID, ()): @@ -144,7 +145,12 @@ async def to_code(config): ), synchronous=True, ) -async def enable_to_code(config, action_id, template_arg, args): +async def enable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -160,7 +166,12 @@ async def enable_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def disable_to_code(config, action_id, template_arg, args): +async def disable_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/key_collector/key_collector.cpp b/esphome/components/key_collector/key_collector.cpp index cb7d47b7f0..69b7a6a7c6 100644 --- a/esphome/components/key_collector/key_collector.cpp +++ b/esphome/components/key_collector/key_collector.cpp @@ -14,6 +14,7 @@ void KeyCollector::loop() { } void KeyCollector::dump_config() { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_CONFIG ESP_LOGCONFIG(TAG, "Key Collector:"); if (this->min_length_ > 0) ESP_LOGCONFIG(TAG, " min length: %d", this->min_length_); @@ -35,6 +36,7 @@ void KeyCollector::dump_config() { ESP_LOGCONFIG(TAG, " allowed keys '%s'", this->allowed_keys_.c_str()); if (this->timeout_ > 0) ESP_LOGCONFIG(TAG, " entry timeout: %0.1f", this->timeout_ / 1000.0); +#endif } void KeyCollector::add_provider(key_provider::KeyProvider *provider) { 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/key_collector/text_sensor/__init__.py b/esphome/components/key_collector/text_sensor/__init__.py index 1676cf7bdf..e32d15df2e 100644 --- a/esphome/components/key_collector/text_sensor/__init__.py +++ b/esphome/components/key_collector/text_sensor/__init__.py @@ -4,7 +4,7 @@ from esphome.components.text_sensor import TextSensor import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.cpp_generator import literal -from esphome.types import TemplateArgsType +from esphome.types import ConfigType, TemplateArgsType from .. import CONF_ON_RESULT, CONF_SOURCE_ID, TRIGGER_TYPES, KeyCollector @@ -15,7 +15,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(TextSensor).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_SOURCE_ID]) var = cg.new_Pvariable(config[CONF_ID]) await text_sensor.register_text_sensor(var, config) 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/kmeteriso/sensor.py b/esphome/components/kmeteriso/sensor.py index 4f6cb7d091..3e007d1310 100644 --- a/esphome/components/kmeteriso/sensor.py +++ b/esphome/components/kmeteriso/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index 6df114e93c..c47a80777c 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -7,13 +7,13 @@ namespace esphome::kuntze { static const char *const TAG = "kuntze"; -static const uint8_t CMD_READ_REG = 0x03; static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832}; // Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5) static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8; -void Kuntze::on_modbus_data(const std::vector &data) { +void Kuntze::on_response(std::span request_pdu, std::span response_pdu) { + auto data = modbus::helpers::server_pdu_payload(response_pdu); auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); }; this->waiting_ = false; @@ -76,7 +76,7 @@ void Kuntze::loop() { if (this->waiting_ || (this->state_ == 0)) return; this->last_send_ = now; - send(CMD_READ_REG, REGISTER[this->state_ - 1], 2); + this->read_holding_registers(REGISTER[this->state_ - 1], 2); this->waiting_ = true; } diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index bbd93a22ce..28c8089748 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -4,9 +4,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#include + 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; } @@ -19,7 +21,7 @@ class Kuntze : public PollingComponent, public modbus::ModbusDevice { void loop() override; void update() override; - void on_modbus_data(const std::vector &data) override; + void on_response(std::span request_pdu, std::span response_pdu) override; void dump_config() override; diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index 96b6334730..51d23991e2 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 = ( ) -async def to_code(config): +def _final_validate(config: ConfigType) -> None: + modbus.final_validate_modbus_device("kuntze", role="client")(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: 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/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index d4e6213425..3319c9be4b 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -18,6 +18,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -71,7 +72,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lcd_base/__init__.py b/esphome/components/lcd_base/__init__.py index bf1072ce66..08ec395720 100644 --- a/esphome/components/lcd_base/__init__.py +++ b/esphome/components/lcd_base/__init__.py @@ -1,7 +1,11 @@ +from typing import Any + import esphome.codegen as cg from esphome.components import display import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_DIMENSIONS, CONF_POSITION +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_USER_CHARACTERS = "user_characters" @@ -9,7 +13,7 @@ lcd_base_ns = cg.esphome_ns.namespace("lcd_base") LCDDisplay = lcd_base_ns.class_("LCDDisplay", cg.PollingComponent) -def validate_lcd_dimensions(value): +def validate_lcd_dimensions(value: Any) -> list[int]: value = cv.dimensions(value) if value[0] > 0x40: raise cv.Invalid("LCD displays can't have more than 64 columns") @@ -18,7 +22,7 @@ def validate_lcd_dimensions(value): return value -def validate_user_characters(value): +def validate_user_characters(value: list[ConfigType]) -> list[ConfigType]: positions = set() for conf in value: if conf[CONF_POSITION] in positions: @@ -51,7 +55,7 @@ LCD_SCHEMA = display.BASIC_DISPLAY_SCHEMA.extend( ).extend(cv.polling_component_schema("1s")) -async def setup_lcd_display(var, config): +async def setup_lcd_display(var: MockObj, config: ConfigType) -> None: await display.register_display(var, config) cg.add(var.set_dimensions(config[CONF_DIMENSIONS][0], config[CONF_DIMENSIONS][1])) if CONF_USER_CHARACTERS in config: diff --git a/esphome/components/lcd_gpio/display.py b/esphome/components/lcd_gpio/display.py index 0a77daf336..10e21c87d1 100644 --- a/esphome/components/lcd_gpio/display.py +++ b/esphome/components/lcd_gpio/display.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_RS_PIN, CONF_RW_PIN, ) +from esphome.types import ConfigType AUTO_LOAD = ["lcd_base"] @@ -17,7 +18,7 @@ lcd_gpio_ns = cg.esphome_ns.namespace("lcd_gpio") GPIOLCDDisplay = lcd_gpio_ns.class_("GPIOLCDDisplay", lcd_base.LCDDisplay) -def validate_pin_length(value): +def validate_pin_length(value: list[ConfigType]) -> list[ConfigType]: if len(value) != 4 and len(value) != 8: raise cv.Invalid( f"LCD Displays can either operate in 4-pin or 8-pin mode,not {len(value)}-pin mode" @@ -38,7 +39,7 @@ CONFIG_SCHEMA = lcd_base.LCD_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await lcd_base.setup_lcd_display(var, config) pins_ = [await cg.gpio_pin_expression(conf) for conf in config[CONF_DATA_PINS]] 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/__init__.py b/esphome/components/lcd_menu/__init__.py index 3f3162e31e..88b8ac21d4 100644 --- a/esphome/components/lcd_menu/__init__.py +++ b/esphome/components/lcd_menu/__init__.py @@ -8,6 +8,7 @@ from esphome.components.display_menu_base import ( import esphome.config_validation as cv from esphome.const import CONF_DIMENSIONS, CONF_DISPLAY_ID, CONF_ID from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType CODEOWNERS = ["@numo68"] @@ -29,7 +30,7 @@ LCDCharacterMenuComponent = lcd_menu_ns.class_( MULTI_CONF = True -def validate_lcd_dimensions(config): +def validate_lcd_dimensions(config: ConfigType) -> ConfigType: if config[CONF_DIMENSIONS][0] < MINIMUM_COLUMNS: raise cv.Invalid( f"LCD display must have at least {MINIMUM_COLUMNS} columns to be usable with the menu" @@ -56,7 +57,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) disp = await cg.get_variable(config[CONF_DISPLAY_ID]) 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/display.py b/esphome/components/lcd_pcf8574/display.py index 410c7f81b7..85a79e99ce 100644 --- a/esphome/components/lcd_pcf8574/display.py +++ b/esphome/components/lcd_pcf8574/display.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, lcd_base import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["lcd_base"] @@ -18,7 +19,7 @@ CONFIG_SCHEMA = lcd_base.LCD_SCHEMA.extend( ).extend(i2c.i2c_device_schema(0x3F)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await lcd_base.setup_lcd_display(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/ld2410/__init__.py index 360e56330a..19786f38d3 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,6 +4,9 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PASSWORD, CONF_THROTTLE, CONF_TIMEOUT +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -69,7 +72,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -102,7 +105,12 @@ BLUETOOTH_PASSWORD_SET_SCHEMA = cv.Schema( BLUETOOTH_PASSWORD_SET_SCHEMA, synchronous=True, ) -async def bluetooth_password_set_to_code(config, action_id, template_arg, args): +async def bluetooth_password_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_PASSWORD], args, cg.std_string) 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/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index fb5b5cabff..2b68733532 100644 --- a/esphome/components/ld2410/binary_sensor.py +++ b/esphome/components/ld2410/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -46,7 +47,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button/__init__.py index fa6f31ee25..59a9558331 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -44,7 +45,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) 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.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f..914de8e145 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -8,6 +8,7 @@ #endif #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::ld2410 { @@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 31186b135f..061846f1f1 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) @@ -121,7 +121,7 @@ class LD2410Component : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; #ifdef USE_NUMBER diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number/__init__.py index 01dbcc785d..3500d704a1 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if timeout_config := config.get(CONF_TIMEOUT): n = await number.new_number( 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/__init__.py b/esphome/components/ld2410/select/__init__.py index 9c4f654aa1..e89e3d5997 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): s = await select.new_select( 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/sensor.py b/esphome/components/ld2410/sensor.py index 459018e263..ca42b3a1d3 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -15,6 +15,7 @@ from esphome.const import ( UNIT_CENTIMETER, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -155,7 +156,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if moving_distance_config := config.get(CONF_MOVING_DISTANCE): sens = await sensor.new_sensor(moving_distance_config) diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch/__init__.py index 4276b28a71..6d8053ddd6 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2410_ID, LD2410Component, ld2410_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): s = await switch.new_switch(engineering_mode_config) 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/ld2410/text_sensor.py b/esphome/components/ld2410/text_sensor.py index a34c8ec0d2..25c61a4825 100644 --- a/esphome/components/ld2410/text_sensor.py +++ b/esphome/components/ld2410/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2410_ID, LD2410Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2410_component = await cg.get_variable(config[CONF_LD2410_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index e701d0bda9..82db319861 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@Rihan9"] @@ -40,7 +41,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld2412/binary_sensor.py b/esphome/components/ld2412/binary_sensor.py index 98fa5965cd..80cff014c0 100644 --- a/esphome/components/ld2412/binary_sensor.py +++ b/esphome/components/ld2412/binary_sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_ACCOUNT, ICON_MOTION_SENSOR, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if dynamic_background_correction_status_config := config.get( CONF_DYNAMIC_BACKGROUND_CORRECTION_STATUS diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button/__init__.py index e0ca285265..5a1ea2e6a5 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -54,7 +55,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) 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.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dc..7041b7539f 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 306e7ae31d..a52402c2ea 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) @@ -124,7 +124,7 @@ class LD2412Component : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index b6e1c8d039..1a81c330ad 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -85,7 +86,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): n = await number.new_number( 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/__init__.py b/esphome/components/ld2412/select/__init__.py index a54cd700ed..02ecf2c30f 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_SCALE, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -48,7 +49,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( 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/sensor.py b/esphome/components/ld2412/sensor.py index f562afe0ee..0b6e676931 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_EMPTY, UNIT_PERCENT, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -156,7 +157,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if detection_distance_config := config.get(CONF_DETECTION_DISTANCE): sens = await sensor.new_sensor(detection_distance_config) diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch/__init__.py index 7a87e9e483..e7f71222fd 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2412_ID, LD2412_ns, LD2412Component @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) 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/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index 22fba5193e..c8e9f42ef3 100644 --- a/esphome/components/ld2412/text_sensor.py +++ b/esphome/components/ld2412/text_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_CHIP, ) +from esphome.types import ConfigType from . import CONF_LD2412_ID, LD2412Component @@ -26,7 +27,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld2420/__init__.py b/esphome/components/ld2420/__init__.py index 71a5fa13e4..5a5aabeba0 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@descipher"] @@ -33,7 +34,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld2420/binary_sensor/__init__.py b/esphome/components/ld2420/binary_sensor/__init__.py index 5ebc4a9f63..76b42c0362 100644 --- a/esphome/components/ld2420/binary_sensor/__init__.py +++ b/esphome/components/ld2420/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_HAS_TARGET, CONF_ID, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_HAS_TARGET in config: 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/__init__.py b/esphome/components/ld2420/button/__init__.py index dfeb121c91..cfcffd0922 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -50,7 +51,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if apply_config := config.get(CONF_APPLY_CONFIG): b = await button.new_button(apply_config) 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.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda28..4aa00f8fd4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) { return result; } -float LD2420Component::get_setup_priority() const { return setup_priority::BUS; } - void LD2420Component::dump_config() { ESP_LOGCONFIG(TAG, "LD2420:\n" @@ -746,7 +744,14 @@ void LD2420Component::set_reg_value(uint16_t reg, uint16_t value) { this->send_cmd_from_array(cmd_frame); } -void LD2420Component::handle_cmd_error(uint8_t error) { ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); } +void LD2420Component::handle_cmd_error(uint16_t error) { + if (error < std::size(ERR_MESSAGE)) { + ESP_LOGE(TAG, "Command failed: %s", ERR_MESSAGE[error]); + } else { + // The error word comes from the device reply frame; unknown codes must not index ERR_MESSAGE + ESP_LOGE(TAG, "Command failed: error 0x%04X", error); + } +} int LD2420Component::get_gate_threshold_(uint8_t gate) { uint8_t error; diff --git a/esphome/components/ld2420/ld2420.h b/esphome/components/ld2420/ld2420.h index 358793fe64..e13d0271e1 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}; @@ -105,10 +105,9 @@ class LD2420Component : public Component, public uart::UARTDevice { void apply_config_action(); void factory_reset_action(); void revert_config_action(); - float get_setup_priority() const override; int send_cmd_from_array(CmdFrameT cmd_frame); void report_gate_data(); - void handle_cmd_error(uint8_t error); + void handle_cmd_error(uint16_t error); void set_operating_mode(const char *state); void auto_calibrate_sensitivity(); void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number); diff --git a/esphome/components/ld2420/number/__init__.py b/esphome/components/ld2420/number/__init__.py index a2637b7b06..448639c911 100644 --- a/esphome/components/ld2420/number/__init__.py +++ b/esphome/components/ld2420/number/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_TIMELAPSE, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -113,7 +114,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if gate_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( 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/__init__.py b/esphome/components/ld2420/select/__init__.py index b9059c120f..cd66064e47 100644 --- a/esphome/components/ld2420/select/__init__.py +++ b/esphome/components/ld2420/select/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -23,7 +24,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: LD2420_component = await cg.get_variable(config[CONF_LD2420_ID]) if operating_mode_config := config.get(CONF_OPERATING_MODE): sel = await select.new_select( 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/__init__.py b/esphome/components/ld2420/sensor/__init__.py index 97acdabd7b..f98d63585b 100644 --- a/esphome/components/ld2420/sensor/__init__.py +++ b/esphome/components/ld2420/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CENTIMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -30,7 +31,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_MOVING_DISTANCE in config: 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/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index 14d982e5fb..cee8f25c1f 100644 --- a/esphome/components/ld2420/text_sensor/__init__.py +++ b/esphome/components/ld2420/text_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType from .. import CONF_LD2420_ID, LD2420Component, ld2420_ns @@ -24,7 +25,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) if CONF_FW_VERSION in config: 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/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 585c9f7bf5..4c37f4fcd1 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE +from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] @@ -49,7 +50,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 89e629253a..779d151fd9 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -39,7 +40,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if has_target_config := config.get(CONF_HAS_TARGET): sens = await binary_sensor.new_binary_sensor(has_target_config) diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button/__init__.py index 682487d750..42cadd2052 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( ICON_RESTART, ICON_RESTART_ALERT, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if factory_reset_config := config.get(CONF_FACTORY_RESET): b = await button.new_button(factory_reset_config) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 0dc2638aad..4b41d63a88 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 10f9bb874a..c4f06ad224 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t moving_presence_millis_ = 0; uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t zone_type_ = 0; diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number/__init__.py index 799c0703f2..4f242076d6 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( UNIT_MILLIMETER, UNIT_SECOND, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -78,7 +79,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): n = await number.new_number( diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index 4f237dc94f..d91b42426a 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -7,6 +7,7 @@ from esphome.const import ( ENTITY_CATEGORY_CONFIG, ICON_THERMOMETER, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -31,7 +32,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if baud_rate_config := config.get(CONF_BAUD_RATE): s = await select.new_select( diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index ce58cedf11..40462e202d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT import esphome.config_validation as cv from esphome.const import ( CONF_ANGLE, @@ -14,6 +15,7 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MILLIMETER, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -21,7 +23,6 @@ DEPENDENCIES = ["ld2450"] CONF_MOVING_TARGET_COUNT = "moving_target_count" CONF_STILL_TARGET_COUNT = "still_target_count" -CONF_TARGET_COUNT = "target_count" ICON_ACCOUNT_GROUP = "mdi:account-group" ICON_ACCOUNT_SWITCH = "mdi:account-switch" @@ -226,7 +227,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if target_count_config := config.get(CONF_TARGET_COUNT): diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch/__init__.py index 0c0c92377b..084f79ee1b 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BLUETOOTH, ICON_PULSE, ) +from esphome.types import ConfigType from .. import CONF_LD2450_ID, LD2450Component, ld2450_ns @@ -35,7 +36,7 @@ CONFIG_SCHEMA = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if bluetooth_config := config.get(CONF_BLUETOOTH): s = await switch.new_switch(bluetooth_config) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 4e5d7d419b..a8b978ef48 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( ICON_CHIP, ICON_SIGN_DIRECTION, ) +from esphome.types import ConfigType from . import CONF_LD2450_ID, LD2450Component @@ -49,7 +50,7 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) if version_config := config.get(CONF_VERSION): sens = await text_sensor.new_text_sensor(version_config) diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index cba1b68a15..deac04e86f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; // Helper function to format MAC address with stack allocation // Returns pointer to UNKNOWN_MAC constant or formatted buffer -// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) -inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { if (mac_address_is_valid(mac_address)) { format_mac_addr_upper(mac_address, buffer.data()); return buffer.data(); diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py new file mode 100644 index 0000000000..af1e501a6a --- /dev/null +++ b/esphome/components/ld6002b/__init__.py @@ -0,0 +1,74 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_WAKEUP_PIN +from esphome.types import ConfigType + +from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE + +CODEOWNERS = ["@hepter"] +DEPENDENCIES = ["uart"] +MULTI_CONF = True + +ld6002b_ns = cg.esphome_ns.namespace("ld6002b") +LD6002BComponent = ld6002b_ns.class_("LD6002BComponent", cg.Component, uart.UARTDevice) + + +def _validate_wakeup_options(config: ConfigType) -> ConfigType: + """Reject wake options that would silently do nothing. + + Runs before the schema so the defaults for the keys below have not been + filled in yet and an explicit user value is still distinguishable from one. + """ + if not isinstance(config, dict): + return config + if CONF_WAKEUP_PIN in config: + return config + for key in (CONF_AUTO_WAKE, CONF_WAKEUP_PULSE): + if key in config: + raise cv.Invalid( + f"'{key}' requires '{CONF_WAKEUP_PIN}' to be configured", path=[key] + ) + return config + + +CONFIG_SCHEMA = cv.All( + _validate_wakeup_options, + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD6002BComponent), + cv.Optional(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, + cv.Optional( + CONF_WAKEUP_PULSE, default="50ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AUTO_WAKE, default=True): cv.boolean, + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ld6002b", + baud_rate=115200, + require_tx=True, + require_rx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if wakeup_pin_config := config.get(CONF_WAKEUP_PIN): + pin = await cg.gpio_pin_expression(wakeup_pin_config) + cg.add(var.set_wakeup_pin(pin)) + + cg.add(var.set_wakeup_pulse_ms(config[CONF_WAKEUP_PULSE].total_milliseconds)) + + cg.add(var.set_auto_wake(config[CONF_AUTO_WAKE])) diff --git a/esphome/components/ld6002b/binary_sensor.py b/esphome/components/ld6002b/binary_sensor.py new file mode 100644 index 0000000000..74095d5ded --- /dev/null +++ b/esphome/components/ld6002b/binary_sensor.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_TARGET, DEVICE_CLASS_OCCUPANCY +from esphome.types import ConfigType + +from . import LD6002BComponent +from .const import AREA_COUNT, CONF_LD6002B_ID, MAX_TARGETS + +DEPENDENCIES = ["ld6002b"] + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ), + } + ) + .extend( + { + cv.Optional(f"target_{i + 1}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(MAX_TARGETS) + } + ) + .extend( + { + cv.Optional(f"detection_area_{i}"): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ) + for i in range(AREA_COUNT) + } + ) +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + if target_config := config.get(CONF_TARGET): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_presence_binary_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + sens = await binary_sensor.new_binary_sensor(target_config) + cg.add(hub.set_target_presence_binary_sensor(i, sens)) + + for i in range(AREA_COUNT): + if area_config := config.get(f"detection_area_{i}"): + sens = await binary_sensor.new_binary_sensor(area_config) + cg.add(hub.set_area_presence_binary_sensor(i, sens)) diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button/__init__.py new file mode 100644 index 0000000000..a664890a86 --- /dev/null +++ b/esphome/components/ld6002b/button/__init__.py @@ -0,0 +1,137 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ( + CONF_AREA_ID, + CONF_ID, + CONF_WAKEUP_PIN, + ENTITY_CATEGORY_CONFIG, + ENTITY_CATEGORY_DIAGNOSTIC, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_APPLY_AREA, + CONF_AUTO_INTERFERENCE, + CONF_CLEAR_INTERFERENCE, + CONF_GET_AREAS, + CONF_GET_DELAY, + CONF_GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME, + CONF_GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE, + CONF_LD6002B_ID, + CONF_RESET_DETECTION_AREA, + CONF_RESET_UNATTENDED, + CONF_WAKE, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BButton = ld6002b_ns.class_("LD6002BButton", button.Button) +ButtonType = ld6002b_ns.enum("ButtonType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_APPLY_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AUTO_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_AREAS): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_CLEAR_INTERFERENCE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_RESET_DETECTION_AREA): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_GET_DELAY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_SENSITIVITY): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_TRIGGER_SPEED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_Z_RANGE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_INSTALLATION): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_MODE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_GET_LOW_POWER_SLEEP_TIME): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_RESET_UNATTENDED): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_WAKE): button.button_schema( + LD6002BButton, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +def final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + if config.get(CONF_APPLY_AREA): + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_APPLY_AREA} requires select.area_id for the same ld6002b instance", + path=[CONF_APPLY_AREA], + ) + + if config.get(CONF_WAKE): + hub_path = full_config.get_path_for_id(hub_id) + hub_config = full_config.get_config_for_path(hub_path[:-1]) + if hub_config.get(CONF_WAKEUP_PIN) is None: + raise cv.Invalid( + f"{CONF_WAKE} requires {CONF_WAKEUP_PIN} on the parent ld6002b component", + path=[CONF_WAKE], + ) + + +FINAL_VALIDATE_SCHEMA = final_validate + +BUTTON_MAP = { + CONF_APPLY_AREA: ButtonType.APPLY_AREA, + CONF_AUTO_INTERFERENCE: ButtonType.AUTO_INTERFERENCE, + CONF_GET_AREAS: ButtonType.GET_AREAS, + CONF_CLEAR_INTERFERENCE: ButtonType.CLEAR_INTERFERENCE, + CONF_RESET_DETECTION_AREA: ButtonType.RESET_DETECTION_AREA, + CONF_GET_DELAY: ButtonType.GET_DELAY, + CONF_GET_SENSITIVITY: ButtonType.GET_SENSITIVITY, + CONF_GET_TRIGGER_SPEED: ButtonType.GET_TRIGGER_SPEED, + CONF_GET_Z_RANGE: ButtonType.GET_Z_RANGE, + CONF_GET_INSTALLATION: ButtonType.GET_INSTALLATION, + CONF_GET_LOW_POWER_MODE: ButtonType.GET_LOW_POWER_MODE, + CONF_GET_LOW_POWER_SLEEP_TIME: ButtonType.GET_LOW_POWER_SLEEP_TIME, + CONF_RESET_UNATTENDED: ButtonType.RESET_UNATTENDED, + CONF_WAKE: ButtonType.WAKE, +} + + +async def to_code(config: ConfigType) -> None: + for key, button_type in BUTTON_MAP.items(): + if button_config := config.get(key): + b = cg.new_Pvariable(button_config[CONF_ID], button_type) + await button.register_button(b, button_config) + await cg.register_parented(b, config[CONF_LD6002B_ID]) diff --git a/esphome/components/ld6002b/button/ld6002b_button.cpp b/esphome/components/ld6002b/button/ld6002b_button.cpp new file mode 100644 index 0000000000..fb398a9a59 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.cpp @@ -0,0 +1,7 @@ +#include "ld6002b_button.h" + +namespace esphome::ld6002b { + +void LD6002BButton::press_action() { this->parent_->press_button(this->type_); } + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/button/ld6002b_button.h b/esphome/components/ld6002b/button/ld6002b_button.h new file mode 100644 index 0000000000..c222143453 --- /dev/null +++ b/esphome/components/ld6002b/button/ld6002b_button.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BButton : public button::Button, public Parented { + public: + explicit LD6002BButton(ButtonType type) : type_(type) {} + + protected: + void press_action() override; + + ButtonType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/const.py b/esphome/components/ld6002b/const.py new file mode 100644 index 0000000000..b7c3f54a6f --- /dev/null +++ b/esphome/components/ld6002b/const.py @@ -0,0 +1,41 @@ +CONF_APPLY_AREA = "apply_area" +CONF_AREA_CONFIG = "area_config" +CONF_AUTO_INTERFERENCE = "auto_interference" +CONF_AUTO_WAKE = "auto_wake" +CONF_CLEAR_INTERFERENCE = "clear_interference" +CONF_CLUSTER_ID = "cluster_id" +CONF_DOPPLER_INDEX = "doppler_index" +CONF_GET_AREAS = "get_areas" +CONF_GET_DELAY = "get_delay" +CONF_GET_INSTALLATION = "get_installation" +CONF_GET_LOW_POWER_MODE = "get_low_power_mode" +CONF_GET_LOW_POWER_SLEEP_TIME = "get_low_power_sleep_time" +CONF_GET_SENSITIVITY = "get_sensitivity" +CONF_GET_TRIGGER_SPEED = "get_trigger_speed" +CONF_GET_Z_RANGE = "get_z_range" +CONF_HOLD_DELAY = "hold_delay" +CONF_INSTALLATION_MODE = "installation_mode" +CONF_LD6002B_ID = "ld6002b_id" +CONF_LOW_POWER = "low_power" +CONF_LOW_POWER_SLEEP_TIME = "low_power_sleep_time" +CONF_OTA_VERSION = "ota_version" +CONF_POINT_CLOUD = "point_cloud" +CONF_POINT_COUNT = "point_count" +CONF_RESET_DETECTION_AREA = "reset_detection_area" +CONF_RESET_UNATTENDED = "reset_unattended" +CONF_TARGET_DISPLAY = "target_display" +CONF_TRIGGER_SPEED = "trigger_speed" +CONF_WAKE = "wake" +CONF_WAKEUP_PULSE = "wakeup_pulse" +CONF_WORK_MODE = "work_mode" +CONF_Z = "z" +CONF_Z_MAX = "z_max" +CONF_Z_MIN = "z_min" + +KEY_X_MIN = "x_min" +KEY_X_MAX = "x_max" +KEY_Y_MIN = "y_min" +KEY_Y_MAX = "y_max" + +AREA_COUNT = 4 +MAX_TARGETS = 3 diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp new file mode 100644 index 0000000000..ca6b9b9552 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -0,0 +1,1879 @@ +#include "ld6002b.h" +#include "esphome/core/log.h" +#include +#include +#include +#include +#include + +namespace esphome::ld6002b { + +static const char *const TAG = "ld6002b"; + +static constexpr uint8_t TF_SOF = 0x01; +static constexpr uint32_t SETUP_DELAY_MS = 100; + +// Command/message types +static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_AREA = 0x0202; +static constexpr uint16_t TYPE_SET_HOLD_DELAY = 0x0203; +static constexpr uint16_t TYPE_SET_Z_RANGE = 0x0204; +static constexpr uint16_t TYPE_SET_LOW_POWER_SLEEP = 0x0205; + +static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; +static constexpr uint16_t TYPE_REPORT_POINT_CLOUD = 0x0A08; +static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; +static constexpr uint16_t TYPE_REPORT_INTERFERENCE_AREAS = 0x0A0B; +static constexpr uint16_t TYPE_REPORT_DETECTION_AREAS = 0x0A0C; +static constexpr uint16_t TYPE_REPORT_DELAY = 0x0A0D; +static constexpr uint16_t TYPE_REPORT_SENSITIVITY = 0x0A0E; +static constexpr uint16_t TYPE_REPORT_TRIGGER = 0x0A0F; +static constexpr uint16_t TYPE_REPORT_Z_RANGE = 0x0A10; +static constexpr uint16_t TYPE_REPORT_INSTALLATION = 0x0A11; +static constexpr uint16_t TYPE_REPORT_LOW_POWER = 0x0A12; +static constexpr uint16_t TYPE_REPORT_LOW_POWER_SLEEP = 0x0A13; +static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A14; +static constexpr uint16_t TYPE_QUERY_VERSION = 0xFFFF; + +// Control command values for TYPE_CONTROL +static constexpr uint32_t CMD_AUTO_INTERFERENCE = 0x01; +static constexpr uint32_t CMD_GET_AREAS = 0x02; +static constexpr uint32_t CMD_CLEAR_INTERFERENCE = 0x03; +static constexpr uint32_t CMD_RESET_DETECTION_AREA = 0x04; +static constexpr uint32_t CMD_GET_DELAY = 0x05; +static constexpr uint32_t CMD_POINT_CLOUD_ON = 0x06; +static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; +static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; +static constexpr uint32_t CMD_TARGET_DISPLAY_OFF = 0x09; +static constexpr uint32_t CMD_SENSITIVITY_LOW = 0x0A; +static constexpr uint32_t CMD_SENSITIVITY_MEDIUM = 0x0B; +static constexpr uint32_t CMD_SENSITIVITY_HIGH = 0x0C; +static constexpr uint32_t CMD_GET_SENSITIVITY = 0x0D; +static constexpr uint32_t CMD_TRIGGER_SLOW = 0x0E; +static constexpr uint32_t CMD_TRIGGER_MEDIUM = 0x0F; +static constexpr uint32_t CMD_TRIGGER_FAST = 0x10; +static constexpr uint32_t CMD_GET_TRIGGER = 0x11; +static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; +static constexpr uint32_t CMD_INSTALL_TOP = 0x13; +static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; +static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; +static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; +static constexpr uint32_t CMD_LOW_POWER_OFF = 0x17; +static constexpr uint32_t CMD_GET_LOW_POWER = 0x18; +static constexpr uint32_t CMD_GET_LOW_POWER_SLEEP = 0x19; +static constexpr uint32_t CMD_RESET_UNATTENDED = 0x1A; + +static constexpr uint16_t TARGET_DATA_LEN = 20; // x,y,z,dop_idx,cluster_id +static constexpr uint16_t AREA_DATA_LEN = 24; // 6 floats +static constexpr uint16_t AREA_CONFIG_LEN = 28; // int32 + 6 floats +static constexpr uint16_t AREA_PRESENCE_ENTRY_LEN = 4; // uint32 per detection area + +static constexpr uint8_t AREA_ID_DEFAULT = 4; // detection_area_0 for initial display + +static constexpr uint8_t VERSION_QUERY_DATA[] = {0x01, 0x01, 0x00, 0x00}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE +static const char *control_command_name(uint32_t command) { + switch (command) { + case CMD_AUTO_INTERFERENCE: + return "auto_interference"; + case CMD_GET_AREAS: + return "get_areas"; + case CMD_CLEAR_INTERFERENCE: + return "clear_interference"; + case CMD_RESET_DETECTION_AREA: + return "reset_detection_area"; + case CMD_GET_DELAY: + return "get_delay"; + case CMD_POINT_CLOUD_ON: + return "point_cloud_on"; + case CMD_POINT_CLOUD_OFF: + return "point_cloud_off"; + case CMD_TARGET_DISPLAY_ON: + return "target_display_on"; + case CMD_TARGET_DISPLAY_OFF: + return "target_display_off"; + case CMD_SENSITIVITY_LOW: + return "sensitivity_low"; + case CMD_SENSITIVITY_MEDIUM: + return "sensitivity_medium"; + case CMD_SENSITIVITY_HIGH: + return "sensitivity_high"; + case CMD_GET_SENSITIVITY: + return "get_sensitivity"; + case CMD_TRIGGER_SLOW: + return "trigger_slow"; + case CMD_TRIGGER_MEDIUM: + return "trigger_medium"; + case CMD_TRIGGER_FAST: + return "trigger_fast"; + case CMD_GET_TRIGGER: + return "get_trigger"; + case CMD_GET_Z_RANGE: + return "get_z_range"; + case CMD_INSTALL_TOP: + return "install_top"; + case CMD_INSTALL_SIDE: + return "install_side"; + case CMD_GET_INSTALLATION: + return "get_installation"; + case CMD_LOW_POWER_ON: + return "low_power_on"; + case CMD_LOW_POWER_OFF: + return "low_power_off"; + case CMD_GET_LOW_POWER: + return "get_low_power"; + case CMD_GET_LOW_POWER_SLEEP: + return "get_low_power_sleep"; + case CMD_RESET_UNATTENDED: + return "reset_unattended"; + default: + return "unknown"; + } +} + +static const char *frame_type_name(uint16_t type) { + switch (type) { + case TYPE_CONTROL: + return "control"; + case TYPE_SET_AREA: + return "set_area"; + case TYPE_SET_HOLD_DELAY: + return "set_hold_delay"; + case TYPE_SET_Z_RANGE: + return "set_z_range"; + case TYPE_SET_LOW_POWER_SLEEP: + return "set_low_power_sleep"; + case TYPE_REPORT_TARGET: + return "report_target"; + case TYPE_REPORT_POINT_CLOUD: + return "report_point_cloud"; + case TYPE_REPORT_AREA_PRESENCE: + return "report_area_presence"; + case TYPE_REPORT_INTERFERENCE_AREAS: + return "report_interference_areas"; + case TYPE_REPORT_DETECTION_AREAS: + return "report_detection_areas"; + case TYPE_REPORT_DELAY: + return "report_delay"; + case TYPE_REPORT_SENSITIVITY: + return "report_sensitivity"; + case TYPE_REPORT_TRIGGER: + return "report_trigger"; + case TYPE_REPORT_Z_RANGE: + return "report_z_range"; + case TYPE_REPORT_INSTALLATION: + return "report_installation"; + case TYPE_REPORT_LOW_POWER: + return "report_low_power"; + case TYPE_REPORT_LOW_POWER_SLEEP: + return "report_low_power_sleep"; + case TYPE_REPORT_WORK_MODE: + return "report_work_mode"; + case TYPE_QUERY_VERSION: + return "query_version"; + default: + return "unknown"; + } +} + +static bool is_expected_control_report(uint32_t command, uint16_t type) { + switch (command) { + case CMD_GET_AREAS: + return type == TYPE_REPORT_INTERFERENCE_AREAS || type == TYPE_REPORT_DETECTION_AREAS; + case CMD_GET_DELAY: + return type == TYPE_REPORT_DELAY; + case CMD_GET_SENSITIVITY: + return type == TYPE_REPORT_SENSITIVITY; + case CMD_GET_TRIGGER: + return type == TYPE_REPORT_TRIGGER; + case CMD_GET_Z_RANGE: + return type == TYPE_REPORT_Z_RANGE; + case CMD_GET_INSTALLATION: + return type == TYPE_REPORT_INSTALLATION; + case CMD_GET_LOW_POWER: + case CMD_LOW_POWER_ON: + case CMD_LOW_POWER_OFF: + return type == TYPE_REPORT_LOW_POWER; + case CMD_GET_LOW_POWER_SLEEP: + return type == TYPE_REPORT_LOW_POWER_SLEEP; + default: + return false; + } +} +#endif + +uint16_t LD6002BComponent::read_u16_be(const uint8_t *data) { return (static_cast(data[0]) << 8) | data[1]; } + +uint32_t LD6002BComponent::read_u32_le(const uint8_t *data) { + return static_cast(data[0]) | (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | (static_cast(data[3]) << 24); +} + +int32_t LD6002BComponent::read_int32_le(const uint8_t *data) { + uint32_t raw = read_u32_le(data); + int32_t value; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + +float LD6002BComponent::read_f32_le(const uint8_t *data) { + uint32_t raw = read_u32_le(data); + float value; + std::memcpy(&value, &raw, sizeof(value)); + return value; +} + +void LD6002BComponent::write_u32_le(uint8_t *data, uint32_t value) { + data[0] = value & 0xFF; + data[1] = (value >> 8) & 0xFF; + data[2] = (value >> 16) & 0xFF; + data[3] = (value >> 24) & 0xFF; +} + +void LD6002BComponent::write_int32_le(uint8_t *data, int32_t value) { + write_u32_le(data, static_cast(value)); +} + +void LD6002BComponent::write_f32_le(uint8_t *data, float value) { + uint32_t raw; + std::memcpy(&raw, &value, sizeof(raw)); + write_u32_le(data, raw); +} + +void LD6002BComponent::setup() { + // Only the point cloud stream needs the larger frame; nothing resizes the buffer after setup. + bool point_cloud_configured = false; +#ifdef USE_SENSOR + point_cloud_configured = point_cloud_configured || this->point_count_sensor_ != nullptr; +#endif +#ifdef USE_SWITCH + point_cloud_configured = point_cloud_configured || this->point_cloud_switch_ != nullptr; +#endif + this->max_data_len_ = point_cloud_configured ? DEFAULT_MAX_DATA_LEN_POINT_CLOUD : DEFAULT_MAX_DATA_LEN; + // One allocation for the component lifetime; the parser reuses it for the header and every payload. + RAMAllocator allocator; + this->data_buf_ = allocator.allocate(this->max_data_len_); + if (this->data_buf_ == nullptr) { + this->mark_failed(LOG_STR("Failed to allocate frame buffer")); + return; + } + if (this->wakeup_pin_ != nullptr) { + this->wakeup_pin_->setup(); + this->wakeup_pin_->digital_write(true); + } + + this->set_timeout(SETUP_DELAY_MS, [this]() { + bool want_target_stream = false; +#ifdef USE_SENSOR + want_target_stream = want_target_stream || this->target_count_sensor_ != nullptr; + if (!want_target_stream) { + for (const auto &target : this->targets_) { + if (target.x != nullptr || target.y != nullptr || target.z != nullptr || target.dop_idx != nullptr || + target.cluster_id != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif +#ifdef USE_BINARY_SENSOR + want_target_stream = want_target_stream || this->presence_binary_sensor_ != nullptr; + if (!want_target_stream) { + for (auto *sensor : this->target_presence_) { + if (sensor != nullptr) { + want_target_stream = true; + break; + } + } + } +#endif +#ifdef USE_TEXT_SENSOR + // The work mode fallback reads presence off this stream, so it counts as a + // consumer of it here. This only feeds the automatic branch below: with a + // target_display switch configured that switch still decides, and the + // fallback weighs no presence at all while the stream is off. + want_target_stream = want_target_stream || this->work_mode_text_sensor_ != nullptr; +#endif + bool target_display_controlled = false; +#ifdef USE_SWITCH + if (this->target_display_switch_ != nullptr) { + target_display_controlled = true; + // Nothing reports this switch back, so its restored state is the only state + // there is. Restoring through the switch keeps its inversion in the path: + // the restored value is logical, and turn_on()/turn_off() are what turn it + // into the raw command, the published state and the stream flag. + const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); + if (state) { + this->target_display_switch_->turn_on(); + } else { + this->target_display_switch_->turn_off(); + } + } +#endif + if (!target_display_controlled) { + // No switch: the stream follows its consumers. With none, nothing is sent + // and the module's own default stands -- but the reports are gated out + // regardless, because there is nothing configured for them to feed. + this->target_display_enabled_ = want_target_stream; + if (want_target_stream) { + this->send_control_command_(CMD_TARGET_DISPLAY_ON); + } + } + + bool point_cloud_controlled = false; +#ifdef USE_SWITCH + if (this->point_cloud_switch_ != nullptr) { + point_cloud_controlled = true; + // The switch owns the stream, so it is also what applies the restored state: + // driving it rather than the module keeps the entity's inversion in the path. + const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->point_cloud_switch_->turn_on(); + } else { + this->point_cloud_switch_->turn_off(); + } + } +#endif + if (!point_cloud_controlled) { + // No switch: the stream follows the sensor that reads it, which is also what + // the frame buffer above was sized for. + bool want_point_cloud = false; +#ifdef USE_SENSOR + want_point_cloud = this->point_count_sensor_ != nullptr; +#endif + this->send_control_command_(want_point_cloud ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); + this->point_cloud_enabled_ = want_point_cloud; + } + +#ifdef USE_SELECT + if (this->sensitivity_select_ != nullptr) { + this->send_control_command_(CMD_GET_SENSITIVITY); + } + if (this->trigger_speed_select_ != nullptr) { + this->send_control_command_(CMD_GET_TRIGGER); + } + if (this->installation_select_ != nullptr) { + this->send_control_command_(CMD_GET_INSTALLATION); + } +#endif +#ifdef USE_NUMBER + if (this->z_min_number_ != nullptr || this->z_max_number_ != nullptr) { + this->send_control_command_(CMD_GET_Z_RANGE); + } + if (this->low_power_sleep_number_ != nullptr) { + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + } + if (this->hold_delay_number_ != nullptr) { + this->send_control_command_(CMD_GET_DELAY); + } +#endif +#ifdef USE_SWITCH + bool want_low_power = this->low_power_switch_ != nullptr; + if (want_low_power) { + // The module reports this one back, so the query below confirms what it took. + // Driving the switch applies its inversion; it also marks the restored value + // as reported, so the work mode fallback runs on that until the query lands. + const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); + if (state) { + this->low_power_switch_->turn_on(); + } else { + this->low_power_switch_->turn_off(); + } + } +#else + bool want_low_power = false; +#endif +#ifdef USE_TEXT_SENSOR + want_low_power = want_low_power || this->work_mode_text_sensor_ != nullptr; +#endif + if (want_low_power) { + this->send_control_command_(CMD_GET_LOW_POWER); + } + + bool want_area_report = false; +#ifdef USE_SENSOR + for (const auto &area : this->interference_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + if (!want_area_report) { + for (const auto &area : this->detection_areas_) { + if (area.x_min != nullptr || area.x_max != nullptr || area.y_min != nullptr || area.y_max != nullptr || + area.z_min != nullptr || area.z_max != nullptr) { + want_area_report = true; + break; + } + } + } +#endif +#ifdef USE_NUMBER + if (this->area_x_min_number_ != nullptr || this->area_x_max_number_ != nullptr || + this->area_y_min_number_ != nullptr || this->area_y_max_number_ != nullptr || + this->area_z_min_number_ != nullptr || this->area_z_max_number_ != nullptr) { + want_area_report = true; + } +#endif + if (want_area_report) { + this->send_control_command_(CMD_GET_AREAS); + } + + this->init_area_id_pref_(); + this->init_version_pref_(); + +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ != nullptr) { + this->queue_command_(TYPE_QUERY_VERSION, VERSION_QUERY_DATA, sizeof(VERSION_QUERY_DATA)); + } +#endif + }); +} + +void LD6002BComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "HLK-LD6002B:\n" + " Auto wake: %s\n" + " Max data length: %u", + this->auto_wake_ ? "true" : "false", static_cast(this->max_data_len_)); + if (this->wakeup_pin_ != nullptr) { + LOG_PIN(" Wake-up Pin: ", this->wakeup_pin_); + ESP_LOGCONFIG(TAG, " Wake Pulse: %" PRIu32 "ms", this->wakeup_pulse_ms_); + } +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); + LOG_SENSOR(" ", "Point Count", this->point_count_sensor_); + for (auto &target : this->targets_) { + LOG_SENSOR(" ", "Target X", target.x); + LOG_SENSOR(" ", "Target Y", target.y); + LOG_SENSOR(" ", "Target Z", target.z); + LOG_SENSOR(" ", "Target Doppler Index", target.dop_idx); + LOG_SENSOR(" ", "Target Cluster ID", target.cluster_id); + } + for (auto &area : this->interference_areas_) { + LOG_SENSOR(" ", "Interference Area X Min", area.x_min); + LOG_SENSOR(" ", "Interference Area X Max", area.x_max); + LOG_SENSOR(" ", "Interference Area Y Min", area.y_min); + LOG_SENSOR(" ", "Interference Area Y Max", area.y_max); + LOG_SENSOR(" ", "Interference Area Z Min", area.z_min); + LOG_SENSOR(" ", "Interference Area Z Max", area.z_max); + } + for (auto &area : this->detection_areas_) { + LOG_SENSOR(" ", "Detection Area X Min", area.x_min); + LOG_SENSOR(" ", "Detection Area X Max", area.x_max); + LOG_SENSOR(" ", "Detection Area Y Min", area.y_min); + LOG_SENSOR(" ", "Detection Area Y Max", area.y_max); + LOG_SENSOR(" ", "Detection Area Z Min", area.z_min); + LOG_SENSOR(" ", "Detection Area Z Max", area.z_max); + } +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Presence", this->presence_binary_sensor_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + LOG_BINARY_SENSOR(" ", "Target Presence", this->target_presence_[i]); + } + for (uint8_t i = 0; i < AREA_COUNT; i++) { + LOG_BINARY_SENSOR(" ", "Detection Area Presence", this->area_presence_[i]); + } +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Work Mode", this->work_mode_text_sensor_); + LOG_TEXT_SENSOR(" ", "OTA Version", this->ota_version_text_sensor_); +#endif +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Hold Delay", this->hold_delay_number_); + LOG_NUMBER(" ", "Z Min", this->z_min_number_); + LOG_NUMBER(" ", "Z Max", this->z_max_number_); + LOG_NUMBER(" ", "Low Power Sleep", this->low_power_sleep_number_); + LOG_NUMBER(" ", "Area X Min", this->area_x_min_number_); + LOG_NUMBER(" ", "Area X Max", this->area_x_max_number_); + LOG_NUMBER(" ", "Area Y Min", this->area_y_min_number_); + LOG_NUMBER(" ", "Area Y Max", this->area_y_max_number_); + LOG_NUMBER(" ", "Area Z Min", this->area_z_min_number_); + LOG_NUMBER(" ", "Area Z Max", this->area_z_max_number_); +#endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Low Power", this->low_power_switch_); + LOG_SWITCH(" ", "Point Cloud", this->point_cloud_switch_); + LOG_SWITCH(" ", "Target Display", this->target_display_switch_); +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Sensitivity", this->sensitivity_select_); + LOG_SELECT(" ", "Trigger Speed", this->trigger_speed_select_); + LOG_SELECT(" ", "Installation Mode", this->installation_select_); + LOG_SELECT(" ", "Area ID", this->area_id_select_); +#endif +} + +void LD6002BComponent::loop() { + while (this->available()) { + uint8_t byte = this->read(); + this->parse_byte_(byte); + } + this->process_command_queue_(); +} + +void LD6002BComponent::reset_parser_() { + this->parse_state_ = ParseState::SOF; + this->header_pos_ = 0; + this->header_xor_ = 0; + this->data_len_ = 0; + this->data_pos_ = 0; + this->data_xor_ = 0; + this->discard_remaining_ = 0; + this->frame_oversize_ = false; +} + +void LD6002BComponent::parse_byte_(uint8_t byte) { + switch (this->parse_state_) { + case ParseState::DISCARD: + // discard_remaining_ is unsigned: an unguarded decrement at zero would swallow 4 GB of stream. + if (this->discard_remaining_ > 0) { + this->discard_remaining_--; + } + if (this->discard_remaining_ == 0) { + this->reset_parser_(); + } + return; + case ParseState::SOF: + if (byte != TF_SOF) + return; + this->header_pos_ = 0; + this->header_xor_ = 0; + this->header_xor_ ^= byte; + this->parse_state_ = ParseState::HEADER; + return; + case ParseState::HEADER: + if (this->header_pos_ < 6) { + this->data_buf_[this->header_pos_] = byte; + this->header_xor_ ^= byte; + this->header_pos_++; + if (this->header_pos_ == 6) { + this->frame_id_ = read_u16_be(this->data_buf_); + this->data_len_ = read_u16_be(this->data_buf_ + 2); + this->frame_type_ = read_u16_be(this->data_buf_ + 4); + // The length is only trustworthy once the header checksum has been verified, so just + // remember that the frame is oversized and let the HCK state act on it. + this->frame_oversize_ = this->data_len_ > this->max_data_len_; + this->parse_state_ = ParseState::HCK; + } + } + return; + case ParseState::HCK: { + uint8_t expected = static_cast(~this->header_xor_); + if (byte != expected) { + ESP_LOGV(TAG, "Header checksum mismatch"); + this->reset_parser_(); + return; + } + if (this->frame_oversize_) { + ESP_LOGW(TAG, "Frame too large: %u", this->data_len_); + // The header is verified, so the length can be trusted: skip the payload and its checksum. + this->discard_remaining_ = static_cast(this->data_len_) + 1; + this->parse_state_ = ParseState::DISCARD; + return; + } + if (this->data_len_ == 0) { + this->handle_frame_(this->frame_type_, nullptr, 0); + this->reset_parser_(); + } else { + this->data_pos_ = 0; + this->data_xor_ = 0; + this->parse_state_ = ParseState::DATA; + } + return; + } + case ParseState::DATA: + this->data_buf_[this->data_pos_++] = byte; + this->data_xor_ ^= byte; + if (this->data_pos_ >= this->data_len_) { + this->parse_state_ = ParseState::DCK; + } + return; + case ParseState::DCK: { + uint8_t expected = static_cast(~this->data_xor_); + if (byte == expected) { + this->handle_frame_(this->frame_type_, this->data_buf_, this->data_len_); + } else { + ESP_LOGV(TAG, "Data checksum mismatch"); + } + this->reset_parser_(); + return; + } + } +} + +void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_t len) { + this->last_traffic_ms_ = millis(); + if (this->stale_ack_count_ > 0 && millis() - this->stale_ack_ms_ > STALE_ACK_MAX_AGE_MS) { + this->stale_ack_count_ = 0; + } + // ACKs carry no id and arrive in send order: debt from earlier attempts is paid before the active command. + if (len == 0 && this->stale_ack_count_ > 0 && this->stale_ack_type_ == type) { + this->stale_ack_count_--; + ESP_LOGV(TAG, "Ignoring ACK for command 0x%04X from an earlier attempt (module frame 0x%04X)", type, + this->frame_id_); + return; + } + if (len == 0 && this->command_active_ && this->command_sent_ && type == this->active_command_.type) { + ESP_LOGV(TAG, "ACK for command 0x%04X (module frame 0x%04X)", type, this->frame_id_); + const bool refresh_areas = (type == TYPE_SET_AREA) && this->area_write_in_flight_; + // This settles one expected reply; the rest stay owed and become the debt for the next command. + this->send_generation_++; + this->stale_ack_type_ = type; + this->stale_ack_count_ = this->acks_expected_ > 0 ? static_cast(this->acks_expected_ - 1) : 0; + this->stale_ack_ms_ = millis(); + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->process_command_queue_(); + if (refresh_areas) { + this->area_write_in_flight_ = false; + this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + } + return; + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + const uint32_t active_control_command = + (this->command_active_ && this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (active_control_command != 0 && is_expected_control_report(active_control_command, type)) { + ESP_LOGV(TAG, "Received %s (0x%04X) while waiting for %s (0x%02" PRIX32 ") ACK", frame_type_name(type), type, + control_command_name(active_control_command), active_control_command); + } +#endif + + switch (type) { + case TYPE_REPORT_TARGET: + this->handle_target_report_(data, len); + break; + case TYPE_REPORT_POINT_CLOUD: + this->handle_point_cloud_(data, len); + break; + case TYPE_REPORT_AREA_PRESENCE: + this->handle_area_presence_(data, len); + break; + case TYPE_REPORT_INTERFERENCE_AREAS: + this->handle_area_report_(true, data, len); + break; + case TYPE_REPORT_DETECTION_AREAS: + this->handle_area_report_(false, data, len); + break; + case TYPE_REPORT_DELAY: + this->handle_delay_report_(data, len); + break; + case TYPE_REPORT_SENSITIVITY: + this->handle_sensitivity_report_(data, len); + break; + case TYPE_REPORT_TRIGGER: + this->handle_trigger_speed_report_(data, len); + break; + case TYPE_REPORT_Z_RANGE: + this->handle_z_range_report_(data, len); + break; + case TYPE_REPORT_INSTALLATION: + this->handle_installation_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER: + this->handle_low_power_report_(data, len); + break; + case TYPE_REPORT_LOW_POWER_SLEEP: + this->handle_low_power_sleep_report_(data, len); + break; + case TYPE_REPORT_WORK_MODE: + this->handle_work_mode_report_(data, len); + break; + case TYPE_QUERY_VERSION: + this->handle_version_report_(data, len); + break; + default: + break; + } +} + +void LD6002BComponent::handle_target_report_(const uint8_t *data, uint16_t len) { + // The module stops streaming when it acts on the command, not when the command + // is queued, so trailing frames after an off must not repopulate what + // set_switch_state just cleared. + if (!this->target_display_enabled_) { + return; + } + if (len < 4) + return; + + uint32_t target_num = read_u32_le(data); + uint16_t available = (len - 4) / TARGET_DATA_LEN; + // Un-narrowed: a report of e.g. 256 targets must not truncate to 0 and read as "absent". + const uint32_t reported = std::min(target_num, available); + uint8_t count = static_cast(std::min(reported, MAX_TARGETS)); + + // The module re-sorts its array by cluster id, so slots key on the id to track the person. + std::array wire_cluster{}; + std::array wire_placed{}; + std::array slot_seen{}; + std::array slot_wire{}; + for (uint8_t i = 0; i < count; i++) { + uint16_t cluster_offset = 4 + (i * TARGET_DATA_LEN) + 16; + wire_cluster[i] = static_cast(read_u32_le(data + cluster_offset)); + } + for (uint8_t i = 0; i < count; i++) { + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (this->slot_occupied_[s] && !slot_seen[s] && this->slot_cluster_[s] == wire_cluster[i]) { + slot_seen[s] = true; + wire_placed[i] = true; + slot_wire[s] = i; + break; + } + } + } + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (!slot_seen[s]) { + this->slot_occupied_[s] = false; + } + } + for (uint8_t i = 0; i < count; i++) { + if (wire_placed[i]) { + continue; + } + for (uint8_t s = 0; s < MAX_TARGETS; s++) { + if (!this->slot_occupied_[s]) { + this->slot_occupied_[s] = true; + this->slot_cluster_[s] = wire_cluster[i]; + slot_wire[s] = i; + break; + } + } + } + +#ifdef USE_SENSOR + if (this->target_count_sensor_ != nullptr) { + if (reported != this->last_target_count_) { + this->target_count_sensor_->publish_state(reported); + this->last_target_count_ = reported; + } + } +#endif + + this->target_presence_any_ = (reported > 0); +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); + + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + bool has_target = this->slot_occupied_[i]; + if (has_target) { +#ifdef USE_SENSOR + uint16_t offset = 4 + (slot_wire[i] * TARGET_DATA_LEN); + float x = read_f32_le(data + offset + 0); + float y = read_f32_le(data + offset + 4); + float z = read_f32_le(data + offset + 8); + int32_t dop_idx = read_int32_le(data + offset + 12); + int32_t cluster_id = this->slot_cluster_[i]; + TargetSensors &target = this->targets_[i]; + if (target.x != nullptr) { + target.x->publish_state(x); + } + if (target.y != nullptr) { + target.y->publish_state(y); + } + if (target.z != nullptr) { + target.z->publish_state(z); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(static_cast(dop_idx)); + } + if (target.cluster_id != nullptr) { + if (!this->last_cluster_id_valid_[i] || cluster_id != this->last_cluster_id_[i]) { + target.cluster_id->publish_state(static_cast(cluster_id)); + this->last_cluster_id_[i] = cluster_id; + this->last_cluster_id_valid_[i] = true; + } + } +#endif + } else { +#ifdef USE_SENSOR + this->clear_target_slot_(i); +#endif + } +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + // publish_state() already skips unchanged states, no manual de-dup needed. + this->target_presence_[i]->publish_state(has_target); + } +#endif +#ifdef USE_SENSOR + this->last_target_presence_[i] = has_target; +#endif + } +} + +void LD6002BComponent::handle_point_cloud_(const uint8_t *data, uint16_t len) { + // Same window as the target stream: a frame already in flight must not put the + // count back after the switch cleared it. + if (!this->point_cloud_enabled_) { + return; + } + if (len < 4) + return; + +#ifdef USE_SENSOR + uint32_t point_num = read_u32_le(data); + if (this->point_count_sensor_ != nullptr) { + if (point_num != this->last_point_count_) { + this->point_count_sensor_->publish_state(point_num); + this->last_point_count_ = point_num; + } + } +#endif +} + +// 0x0A0A carries one uint32 per detection area -- the protocol names the four +// fields detection_state_area0..3 -- so this covers area ids 4..7 only. The +// interference areas have no presence report: a target inside one is what they +// exist to suppress. +void LD6002BComponent::handle_area_presence_(const uint8_t *data, uint16_t len) { + const uint16_t needed = AREA_COUNT * AREA_PRESENCE_ENTRY_LEN; + if (len < needed) + return; + + this->area_presence_any_ = false; + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint32_t state = read_u32_le(data + (i * AREA_PRESENCE_ENTRY_LEN)); + bool present = state != 0; + this->area_presence_any_ = this->area_presence_any_ || present; +#ifdef USE_BINARY_SENSOR + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(present); + } +#endif + } + +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_area_report_(bool interference, const uint8_t *data, uint16_t len) { + uint16_t needed = AREA_COUNT * AREA_DATA_LEN; + if (len < needed) + return; + + for (uint8_t i = 0; i < AREA_COUNT; i++) { + uint16_t offset = i * AREA_DATA_LEN; + float x_min = read_f32_le(data + offset + 0); + float x_max = read_f32_le(data + offset + 4); + float y_min = read_f32_le(data + offset + 8); + float y_max = read_f32_le(data + offset + 12); + float z_min = read_f32_le(data + offset + 16); + float z_max = read_f32_le(data + offset + 20); + +#ifdef USE_SENSOR + AreaSensors &area = interference ? this->interference_areas_[i] : this->detection_areas_[i]; + if (area.x_min != nullptr) + area.x_min->publish_state(x_min); + if (area.x_max != nullptr) + area.x_max->publish_state(x_max); + if (area.y_min != nullptr) + area.y_min->publish_state(y_min); + if (area.y_max != nullptr) + area.y_max->publish_state(y_max); + if (area.z_min != nullptr) + area.z_min->publish_state(z_min); + if (area.z_max != nullptr) + area.z_max->publish_state(z_max); +#endif + + AreaConfig &store = interference ? this->interference_area_values_[i] : this->detection_area_values_[i]; + store.x_min = x_min; + store.x_max = x_max; + store.y_min = y_min; + store.y_max = y_max; + store.z_min = z_min; + store.z_max = z_max; + + uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + bool selected_interference = selected_id < AREA_COUNT; + uint8_t selected_index = selected_interference ? selected_id : static_cast(selected_id - AREA_COUNT); + if (selected_interference == interference && selected_index == i) { + this->update_area_numbers_(store); + } + } + this->try_apply_pending_area_(interference); +} + +void LD6002BComponent::handle_delay_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t delay = read_u32_le(data); + this->publish_number_clamped_(this->hold_delay_number_, delay); +#endif +} + +void LD6002BComponent::handle_sensitivity_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->sensitivity_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->sensitivity_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_trigger_speed_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->trigger_speed_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 2) { + this->trigger_speed_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_z_range_report_(const uint8_t *data, uint16_t len) { + if (len < 8) + return; + float z_min = read_f32_le(data); + float z_max = read_f32_le(data + 4); + this->z_min_ = z_min; + this->z_max_ = z_max; +#ifdef USE_NUMBER + this->publish_number_clamped_(this->z_min_number_, z_min); + this->publish_number_clamped_(this->z_max_number_, z_max); +#endif +} + +void LD6002BComponent::handle_installation_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; +#ifdef USE_SELECT + if (this->installation_select_ == nullptr) + return; + uint8_t value = data[0]; + if (value <= 1) { + this->installation_select_->publish_state(value); + } +#endif +} + +void LD6002BComponent::handle_low_power_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; + bool enabled = data[0] != 0; + this->low_power_enabled_ = enabled; + this->low_power_reported_ = true; +#ifdef USE_SWITCH + if (this->low_power_switch_ != nullptr) { + this->low_power_switch_->publish_state(enabled); + } +#endif + this->update_work_mode_fallback_(); +} + +void LD6002BComponent::handle_low_power_sleep_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_NUMBER + uint32_t sleep_ms = read_u32_le(data); + this->publish_number_clamped_(this->low_power_sleep_number_, sleep_ms); +#endif +} + +void LD6002BComponent::handle_work_mode_report_(const uint8_t *data, uint16_t len) { + if (len < 1) + return; + // Zero is the unattended half of this transition. Read outside the text sensor's + // ifdef because the area sensors do not need one configured to have gone stale. + const bool low_power = (data[0] == 0); +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ != nullptr) { + this->work_mode_reported_ = true; + this->publish_work_mode_(low_power); + } +#endif + // Protocol V1.2 section 2.1.17: this message is sent only on the transition + // between the unattended low-power mode and normal operation, so a zero is the + // module stating that nobody is in any area. Not while a target is still being + // tracked, though: the reset_unattended command is undocumented on whether it + // forces this report, and where two statements from the module disagree the live + // one wins. + if (low_power && !this->target_presence_any_) { + this->clear_area_presence_(); + } +} + +void LD6002BComponent::update_work_mode_fallback_() { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr || this->work_mode_reported_) { + return; + } + if (!this->low_power_reported_) { + return; + } + // Target presence is only meaningful while the stream that maintains it runs. + // Area presence keeps its own report, so it still counts with the target stream + // off and low power alone decides only when neither half has anything to say. + const bool presence = (this->target_display_enabled_ && this->target_presence_any_) || this->area_presence_any_; + this->publish_work_mode_(this->low_power_enabled_ && !presence); +#endif +} + +void LD6002BComponent::publish_work_mode_(bool low_power) { +#ifdef USE_TEXT_SENSOR + if (this->work_mode_text_sensor_ == nullptr) { + return; + } + if (this->last_work_mode_valid_ && this->last_work_mode_low_power_ == low_power) { + return; + } + this->work_mode_text_sensor_->publish_state(low_power ? "low_power" : "normal"); + this->last_work_mode_valid_ = true; + this->last_work_mode_low_power_ = low_power; +#endif +} + +#ifdef USE_NUMBER +void LD6002BComponent::publish_number_clamped_(number::Number *number, float value) { + if (number == nullptr) + return; + if (std::isnan(value)) { + // NAN is this component's "the module has not told us yet". Publishing it on an + // entity that has never had a state would report a nan where unknown is the + // truth; on one that already shows a value it is the only way to say that value + // no longer describes the selected area. + if (number->has_state()) { + number->publish_state(value); + } + return; + } + const float min_value = number->traits.get_min_value(); + const float max_value = number->traits.get_max_value(); + // Outside the declared range the user cannot write the value back, so publish + // what they can reach and say what the module actually sent. + if (value < min_value || value > max_value) { + ESP_LOGW(TAG, "'%s': module reported %.1f, clamped to %.1f..%.1f", number->get_name().c_str(), value, min_value, + max_value); + value = std::clamp(value, min_value, max_value); + } + number->publish_state(value); +} +#endif + +void LD6002BComponent::handle_version_report_(const uint8_t *data, uint16_t len) { + if (len < 4) + return; +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) + return; + uint8_t project = data[0]; + uint8_t major = data[1]; + uint8_t minor = data[2]; + uint8_t patch = data[3]; + char buf[32]; + if (project == 0) { + std::snprintf(buf, sizeof(buf), "%u.%u.%u", major, minor, patch); + } else { + std::snprintf(buf, sizeof(buf), "p%u %u.%u.%u", project, major, minor, patch); + } + this->ota_version_text_sensor_->publish_state(buf); + this->save_version_pref_(buf); +#endif +} + +bool LD6002BComponent::queue_command_(uint16_t type, const uint8_t *data, uint8_t len) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + return false; + } + if (this->cmd_count_ >= CMD_QUEUE_SIZE) { + ESP_LOGW(TAG, "Command queue full, dropping command 0x%04X", type); + return false; + } + + PendingCommand &cmd = this->cmd_queue_[this->cmd_tail_]; + cmd.type = type; + cmd.len = len; + if (len > 0 && data != nullptr) { + std::memcpy(cmd.data.data(), data, len); + } + + this->cmd_tail_ = (this->cmd_tail_ + 1) % CMD_QUEUE_SIZE; + this->cmd_count_++; + this->process_command_queue_(); + return true; +} + +void LD6002BComponent::process_command_queue_() { + uint32_t now = millis(); + if (this->command_active_) { + // A sleeping module consumes the opening attempt as its wake-up instead of answering it. + const uint32_t ack_timeout = this->attempts_sent_ <= 1 ? CMD_FIRST_ACK_TIMEOUT_MS : CMD_ACK_TIMEOUT_MS; + if (this->command_sent_ && now - this->last_send_ms_ >= ack_timeout) { + const uint32_t active_control_command = + (this->active_command_.type == TYPE_CONTROL && this->active_command_.len >= 4) + ? read_u32_le(this->active_command_.data.data()) + : 0; + if (this->retries_left_ > 0) { +#ifdef ESPHOME_LOG_HAS_VERBOSE + if (active_control_command != 0) { + ESP_LOGV(TAG, "Retrying %s (0x%02" PRIX32 "), %u attempt(s) remaining", + control_command_name(active_control_command), active_control_command, this->retries_left_); + } else { + // Writes without a control subcommand (hold delay, z-range) had no retry trace at all. + ESP_LOGV(TAG, "Retrying command 0x%04X, %u attempt(s) remaining", this->active_command_.type, + this->retries_left_); + } +#endif + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); + this->retries_left_--; + } else { + if (active_control_command != 0) { + ESP_LOGW(TAG, "Command 0x%04X subcommand 0x%02" PRIX32 " timed out", this->active_command_.type, + active_control_command); + } else { + ESP_LOGW(TAG, "Command 0x%04X timed out", this->active_command_.type); + } + if (this->active_command_.type == TYPE_SET_AREA) { + this->area_write_in_flight_ = false; + } + // The deferred apply is waiting on the report this command would have + // brought back, and nothing else re-arms it. Dropping it here is the + // difference between one apply lost to a timeout and one that rides in on + // an unrelated area report later, writing bounds the user has moved on from. + if (active_control_command == CMD_GET_AREAS && this->deferred_apply_pending_) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Area read timed out, dropping deferred area apply"); + } + // A reply may still be in flight for the attempt we just gave up on, so carry one over as + // debt rather than clearing the ledger, or that late ACK would retire the successor. Only + // one: reaching this point means nothing was answered at all, so the older attempts are + // speculative, and carrying them would swallow the successor's own replies. + const uint16_t owed = (this->stale_ack_type_ == this->active_command_.type ? this->stale_ack_count_ : 0) + + (this->acks_expected_ > 0 ? 1 : 0); + this->stale_ack_type_ = this->active_command_.type; + this->stale_ack_count_ = static_cast(std::min(owed, 255)); + this->stale_ack_ms_ = now; + this->send_generation_++; + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + } + return; + } + + if (this->cmd_count_ == 0) + return; + + this->active_command_ = this->cmd_queue_[this->cmd_head_]; + this->cmd_head_ = (this->cmd_head_ + 1) % CMD_QUEUE_SIZE; + this->cmd_count_--; + + this->send_generation_++; + this->retries_left_ = CMD_MAX_RETRIES; + this->command_active_ = true; + this->command_sent_ = false; + this->last_send_ms_ = 0; + this->attempts_sent_ = 0; + this->acks_expected_ = 0; + if (this->stale_ack_type_ != this->active_command_.type) { + this->stale_ack_count_ = 0; + } + this->send_command_(this->active_command_.type, this->active_command_.data.data(), this->active_command_.len); +} + +void LD6002BComponent::send_command_(uint16_t type, const uint8_t *data, uint8_t len) { + this->send_command_internal_(type, data, len, true); +} + +void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + if (len > CMD_MAX_DATA_LEN) { + ESP_LOGW(TAG, "Command data too large: %u", len); + if (track) { + // Release the slot: an unwritten command is never acked and never times out. + this->command_active_ = false; + this->command_sent_ = false; + this->last_send_ms_ = 0; + } + return; + } + + // Anonymous timeouts never replace each other; with a pulse already pending the module is waking anyway. + if (this->auto_wake_ && this->wakeup_pin_ != nullptr && !this->wake_pulse_pending_) { + // Snapshot the payload: the deferred write must not depend on state a completing command changes. + if (len > 0 && data != nullptr) { + std::memcpy(this->wake_scratch_.data(), data, len); + } + // A button pulse must not raise the pin in the middle of this one. + this->cancel_timeout(WAKE_BUTTON_TIMEOUT); + this->wake_pulse_pending_ = true; + this->wakeup_pin_->digital_write(false); + const uint8_t generation = this->send_generation_; + this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() { + this->wakeup_pin_->digital_write(true); + this->wake_pulse_pending_ = false; + // Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been + // retired must not transmit: the frame would land after its successor and be booked to it. + if (track && generation != this->send_generation_) { + return; + } + this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track); + }); + return; + } + + this->write_frame_(type, data, len, track); +} + +void LD6002BComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track) { + uint16_t frame_id = this->next_frame_id_++ & 0x7FFF; + frame_id |= 0x8000; + + uint8_t header_xor = 0; + auto write_header = [&](uint8_t b) { + this->write_byte(b); + header_xor ^= b; + }; + + write_header(TF_SOF); + write_header((frame_id >> 8) & 0xFF); + write_header(frame_id & 0xFF); + write_header((len >> 8) & 0xFF); + write_header(len & 0xFF); + write_header((type >> 8) & 0xFF); + write_header(type & 0xFF); + + this->write_byte(static_cast(~header_xor)); + + if (len > 0 && data != nullptr) { + uint8_t data_xor = 0; + for (uint8_t i = 0; i < len; i++) { + this->write_byte(data[i]); + data_xor ^= data[i]; + } + this->write_byte(static_cast(~data_xor)); + } + const uint32_t now = millis(); + if (track) { + // A frame sent to a module that has had time to fall asleep is its wake-up, and goes unanswered. + if (this->last_traffic_ms_ != 0 && now - this->last_traffic_ms_ < MODULE_AWAKE_MS) { + this->acks_expected_++; + } + this->last_send_ms_ = now; + this->command_sent_ = true; + this->attempts_sent_++; + } + this->last_traffic_ms_ = now; +} + +bool LD6002BComponent::send_control_command_(uint32_t command) { + uint8_t data[4]; + write_u32_le(data, command); + return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); +} + +void LD6002BComponent::send_z_range_() { + // One frame carries both bounds, so half a range cannot be written. + if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { + ESP_LOGW(TAG, "Z range not written, other bound unknown"); + return; + } + // Both bounds are known and crossed; the frame has no way to say that. + if (this->z_min_ > this->z_max_) { + ESP_LOGW(TAG, "Z range not written, min above max"); + return; + } + uint8_t data[8]; + write_f32_le(data, this->z_min_); + write_f32_le(data + 4, this->z_max_); + this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); +} + +void LD6002BComponent::apply_area_config_() { + if (!this->area_id_set_) { + ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); + return; + } + if (this->area_id_ >= AREA_ID_COUNT) { + ESP_LOGW(TAG, "Invalid area id: %u", this->area_id_); + return; + } + + const bool interference = this->area_id_ < AREA_COUNT; + const uint8_t index = interference ? this->area_id_ : static_cast(this->area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + if (!std::isnan(this->area_x_min_)) + desired.x_min = this->area_x_min_; + if (!std::isnan(this->area_x_max_)) + desired.x_max = this->area_x_max_; + if (!std::isnan(this->area_y_min_)) + desired.y_min = this->area_y_min_; + if (!std::isnan(this->area_y_max_)) + desired.y_max = this->area_y_max_; + if (!std::isnan(this->area_z_min_)) + desired.z_min = this->area_z_min_; + if (!std::isnan(this->area_z_max_)) + desired.z_max = this->area_z_max_; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Ask first: a read that never reached the queue would leave a deferral waiting + // on a report nobody requested, with the user's values already retired for it. + if (!this->send_control_command_(CMD_GET_AREAS)) { + ESP_LOGW(TAG, "Area read not queued; area config left unapplied"); + return; + } + this->deferred_apply_pending_ = true; + this->pending_area_id_ = this->area_id_; + // The ledger, not the mirror: the mirror also carries whatever the module last + // reported for the axes the user never touched, and staging those would hand them + // back later wearing the user's badge -- a module value the next report is then + // kept away from. Staging only what was actually typed is also what makes the + // replay's overlay right: the untouched axes come from the fresh report. An + // empty ledger is a meaning rather than a gap, then: an apply with nothing + // staged rewrites the area exactly as the report just described it, which is + // what a direct apply with nothing staged already does. + this->pending_area_updates_ = this->area_edits_; + // Staged above, so they are the deferred apply's values now rather than an + // unsent edit. Anything typed from here belongs to whatever the user does + // next, which may well be a different area. + this->area_edits_ = AreaConfig{}; + ESP_LOGI(TAG, "Area config incomplete; requesting current areas before applying"); + return; + } + // Only a write the module will actually see retires them. + if (this->queue_area_config_(this->area_id_, desired)) { + this->area_edits_ = AreaConfig{}; + } +} + +void LD6002BComponent::wake_() { + // A command's own pulse raises the pin and writes after it, so ride along instead of + // claiming the flag: claiming it would send that command down the immediate-write path + // with the pin still low. + if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) + return; + this->wakeup_pin_->digital_write(false); + this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); +} + +void LD6002BComponent::set_number_value(NumberType type, float value) { + switch (type) { + case NumberType::HOLD_DELAY: { + uint32_t delay = static_cast(value); + uint8_t data[4]; + write_u32_le(data, delay); + this->queue_command_(TYPE_SET_HOLD_DELAY, data, sizeof(data)); + break; + } + case NumberType::Z_MIN: + this->z_min_ = value; + this->send_z_range_(); + break; + case NumberType::Z_MAX: + this->z_max_ = value; + this->send_z_range_(); + break; + case NumberType::LOW_POWER_SLEEP: { + uint32_t sleep_ms = static_cast(value); + uint8_t data[4]; + write_u32_le(data, sleep_ms); + this->queue_command_(TYPE_SET_LOW_POWER_SLEEP, data, sizeof(data)); + break; + } + case NumberType::AREA_X_MIN: + this->area_x_min_ = value; + this->area_edits_.x_min = value; + break; + case NumberType::AREA_X_MAX: + this->area_x_max_ = value; + this->area_edits_.x_max = value; + break; + case NumberType::AREA_Y_MIN: + this->area_y_min_ = value; + this->area_edits_.y_min = value; + break; + case NumberType::AREA_Y_MAX: + this->area_y_max_ = value; + this->area_edits_.y_max = value; + break; + case NumberType::AREA_Z_MIN: + this->area_z_min_ = value; + this->area_edits_.z_min = value; + break; + case NumberType::AREA_Z_MAX: + this->area_z_max_ = value; + this->area_edits_.z_max = value; + break; + } +} + +void LD6002BComponent::set_select_value(SelectType type, size_t index) { + switch (type) { + case SelectType::SENSITIVITY: + if (index == 0) { + this->send_control_command_(CMD_SENSITIVITY_LOW); + } else if (index == 1) { + this->send_control_command_(CMD_SENSITIVITY_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_SENSITIVITY_HIGH); + } + break; + case SelectType::TRIGGER_SPEED: + if (index == 0) { + this->send_control_command_(CMD_TRIGGER_SLOW); + } else if (index == 1) { + this->send_control_command_(CMD_TRIGGER_MEDIUM); + } else if (index == 2) { + this->send_control_command_(CMD_TRIGGER_FAST); + } + break; + case SelectType::INSTALLATION_MODE: + if (index == 0) { + this->send_control_command_(CMD_INSTALL_TOP); + } else if (index == 1) { + this->send_control_command_(CMD_INSTALL_SIDE); + } + break; + case SelectType::AREA_ID: + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + break; + } +} + +void LD6002BComponent::update_area_numbers_(const AreaConfig &area) { + // A report refreshes every axis the user is not in the middle of changing. An + // unapplied edit is the one value here the module cannot know about, so taking + // the report over it would discard what the user typed with nothing to show for it. + const AreaConfig &edits = this->area_edits_; + if (std::isnan(edits.x_min)) + this->area_x_min_ = area.x_min; + if (std::isnan(edits.x_max)) + this->area_x_max_ = area.x_max; + if (std::isnan(edits.y_min)) + this->area_y_min_ = area.y_min; + if (std::isnan(edits.y_max)) + this->area_y_max_ = area.y_max; + if (std::isnan(edits.z_min)) + this->area_z_min_ = area.z_min; + if (std::isnan(edits.z_max)) + this->area_z_max_ = area.z_max; + this->publish_area_numbers_(); +} + +// The mirror, not the report: an axis a report was kept away from has to keep its +// displayed value too, or the entity and the value the next apply sends disagree. +void LD6002BComponent::publish_area_numbers_() { +#ifdef USE_NUMBER + this->publish_number_clamped_(this->area_x_min_number_, this->area_x_min_); + this->publish_number_clamped_(this->area_x_max_number_, this->area_x_max_); + this->publish_number_clamped_(this->area_y_min_number_, this->area_y_min_); + this->publish_number_clamped_(this->area_y_max_number_, this->area_y_max_); + this->publish_number_clamped_(this->area_z_min_number_, this->area_z_min_); + this->publish_number_clamped_(this->area_z_max_number_, this->area_z_max_); +#endif +} + +void LD6002BComponent::update_area_numbers_for_id_(uint8_t area_id) { + if (area_id >= AREA_ID_COUNT) + return; + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + const AreaConfig &area = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + // The edits belonged to the area being navigated away from. + this->area_edits_ = AreaConfig{}; + this->update_area_numbers_(area); +} + +bool LD6002BComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desired) { + // One frame carries all three pairs and cannot express a crossed one; the module + // would keep a box nothing can ever be inside. Both callers arrive with the six + // bounds resolved, so this is the last place that can say no -- and the return + // value is how saying no reaches the caller, which must not then retire the edits + // the user still has to fix. + if (desired.x_min > desired.x_max || desired.y_min > desired.y_max || desired.z_min > desired.z_max) { + ESP_LOGW(TAG, "Area %u not written, min above max", area_id); + return false; + } + uint8_t data[AREA_CONFIG_LEN]; + write_int32_le(data, static_cast(area_id)); + write_f32_le(data + 4, desired.x_min); + write_f32_le(data + 8, desired.x_max); + write_f32_le(data + 12, desired.y_min); + write_f32_le(data + 16, desired.y_max); + write_f32_le(data + 20, desired.z_min); + write_f32_le(data + 24, desired.z_max); + + if (!this->queue_command_(TYPE_SET_AREA, data, sizeof(data))) { + // Nothing is on its way, so the cache must not claim these bounds, the ack + // refresh must not be armed for an ack that cannot come, and the values stay + // the user's unsent edit. + return false; + } + this->area_write_in_flight_ = true; + + const bool interference = area_id < AREA_COUNT; + const uint8_t index = interference ? area_id : static_cast(area_id - AREA_COUNT); + AreaConfig &store = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + store = desired; + // The six numbers show one area at a time, and a deferred apply can land here for + // an area the user has navigated away from. Same question handle_area_report_ + // asks before it touches them. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (area_id == selected_id) { + this->update_area_numbers_(store); + } + return true; +} + +void LD6002BComponent::try_apply_pending_area_(bool reported_interference) { + if (!this->deferred_apply_pending_) { + return; + } + if (this->pending_area_id_ >= AREA_ID_COUNT) { + this->deferred_apply_pending_ = false; + return; + } + const bool interference = this->pending_area_id_ < AREA_COUNT; + const uint8_t index = + interference ? this->pending_area_id_ : static_cast(this->pending_area_id_ - AREA_COUNT); + AreaConfig desired = interference ? this->interference_area_values_[index] : this->detection_area_values_[index]; + + if (!std::isnan(this->pending_area_updates_.x_min)) + desired.x_min = this->pending_area_updates_.x_min; + if (!std::isnan(this->pending_area_updates_.x_max)) + desired.x_max = this->pending_area_updates_.x_max; + if (!std::isnan(this->pending_area_updates_.y_min)) + desired.y_min = this->pending_area_updates_.y_min; + if (!std::isnan(this->pending_area_updates_.y_max)) + desired.y_max = this->pending_area_updates_.y_max; + if (!std::isnan(this->pending_area_updates_.z_min)) + desired.z_min = this->pending_area_updates_.z_min; + if (!std::isnan(this->pending_area_updates_.z_max)) + desired.z_max = this->pending_area_updates_.z_max; + + if (std::isnan(desired.x_min) || std::isnan(desired.x_max) || std::isnan(desired.y_min) || + std::isnan(desired.y_max) || std::isnan(desired.z_min) || std::isnan(desired.z_max)) { + // Only the report covering this area's half can still fill it in, and there is + // exactly one of those per read. Once it has landed with a bound still unknown, + // nothing further is coming and waiting means waiting forever. + if (reported_interference == interference) { + this->deferred_apply_pending_ = false; + this->restore_deferred_edits_(); + ESP_LOGW(TAG, "Dropping deferred area apply, area report incomplete"); + } + return; + } + + const uint8_t area_id = this->pending_area_id_; + this->deferred_apply_pending_ = false; + if (!this->queue_area_config_(area_id, desired)) { + // Nothing was queued, so this is a drop like the other two: hand the staged + // values back rather than leaving them with no ledger to protect them. + this->restore_deferred_edits_(); + } +} + +void LD6002BComponent::init_area_id_pref_() { +#ifdef USE_SELECT + if (this->area_id_select_ == nullptr) { + return; + } + this->area_id_pref_ = this->area_id_select_->make_entity_preference(); + this->area_id_pref_initialized_ = true; + + uint8_t value = 0; + if (!this->area_id_pref_.load(&value) || value >= AREA_ID_COUNT) { + // No stored selection. The numbers are about to display this area either way, + // so select it for real: a displayed area that apply_area then refuses to write + // is the one combination the user cannot make sense of. + value = AREA_ID_DEFAULT; + } + this->area_id_select_->publish_state(value); + this->area_id_ = value; + this->area_id_set_ = true; + this->update_area_numbers_for_id_(value); +#endif +} + +void LD6002BComponent::save_area_id_pref_(uint8_t value) { +#ifdef USE_SELECT + if (!this->area_id_pref_initialized_) { + return; + } + this->area_id_pref_.save(&value); +#endif +} + +void LD6002BComponent::init_version_pref_() { +#ifdef USE_TEXT_SENSOR + if (this->ota_version_text_sensor_ == nullptr) { + return; + } + this->version_pref_ = this->ota_version_text_sensor_->make_entity_preference(); + this->version_pref_initialized_ = true; + + VersionPref pref{}; + if (this->version_pref_.load(&pref) && pref.value[0] != '\0') { + pref.value[sizeof(pref.value) - 1] = '\0'; + this->ota_version_text_sensor_->publish_state(pref.value); + } +#endif +} + +void LD6002BComponent::save_version_pref_(const char *value) { +#ifdef USE_TEXT_SENSOR + if (!this->version_pref_initialized_) { + return; + } + VersionPref pref{}; + std::strncpy(pref.value, value, sizeof(pref.value) - 1); + pref.value[sizeof(pref.value) - 1] = '\0'; + this->version_pref_.save(&pref); +#endif +} + +#ifdef USE_SENSOR +void LD6002BComponent::clear_target_slot_(uint8_t index) { + if (!this->last_target_presence_[index]) { + return; + } + TargetSensors &target = this->targets_[index]; + if (target.x != nullptr) { + target.x->publish_state(NAN); + } + if (target.y != nullptr) { + target.y->publish_state(NAN); + } + if (target.z != nullptr) { + target.z->publish_state(NAN); + } + if (target.dop_idx != nullptr) { + target.dop_idx->publish_state(NAN); + } + if (target.cluster_id != nullptr) { + target.cluster_id->publish_state(NAN); + } + // The slot is free: the next person's id is new even when it repeats this one. + this->last_cluster_id_valid_[index] = false; +} +#endif + +void LD6002BComponent::restore_deferred_edits_() { + // The staged values become an unsent edit again, but only for the user who is + // still looking at the area they were staged for; anyone else's ledger belongs to + // the area they are on now. + const uint8_t selected_id = this->area_id_set_ ? this->area_id_ : AREA_ID_DEFAULT; + if (this->pending_area_id_ != selected_id) { + return; + } + // Axis by axis rather than a whole-struct assignment: the user can have edited + // another bound while the deferral was in flight, and that edit is newer than + // anything the deferral staged. Assigning over the ledger would drop it back to + // NaN and let the next report take the value away. A live edit wins; only an axis + // with nothing in the ledger takes its staged value back. + // + // The mirror moves with the ledger, because on the report path handle_area_report_ + // ran update_area_numbers_ before the replay, with the ledger still empty -- so the + // mirror already holds the module's bounds and both the entities and the next apply + // would build on them. On the timeout path no report arrived, the mirror still + // holds the staged values, and this is an identity. + const AreaConfig &staged = this->pending_area_updates_; + if (std::isnan(this->area_edits_.x_min) && !std::isnan(staged.x_min)) { + this->area_edits_.x_min = staged.x_min; + this->area_x_min_ = staged.x_min; + } + if (std::isnan(this->area_edits_.x_max) && !std::isnan(staged.x_max)) { + this->area_edits_.x_max = staged.x_max; + this->area_x_max_ = staged.x_max; + } + if (std::isnan(this->area_edits_.y_min) && !std::isnan(staged.y_min)) { + this->area_edits_.y_min = staged.y_min; + this->area_y_min_ = staged.y_min; + } + if (std::isnan(this->area_edits_.y_max) && !std::isnan(staged.y_max)) { + this->area_edits_.y_max = staged.y_max; + this->area_y_max_ = staged.y_max; + } + if (std::isnan(this->area_edits_.z_min) && !std::isnan(staged.z_min)) { + this->area_edits_.z_min = staged.z_min; + this->area_z_min_ = staged.z_min; + } + if (std::isnan(this->area_edits_.z_max) && !std::isnan(staged.z_max)) { + this->area_edits_.z_max = staged.z_max; + this->area_z_max_ = staged.z_max; + } + this->publish_area_numbers_(); +} + +void LD6002BComponent::clear_area_presence_() { + if (!this->area_presence_any_) { + return; + } + // Nothing else corrects this: 0x0A0A carries no period the protocol states and no + // command stops it, so the module going unattended is the only moment the + // component can know a stored "occupied" has stopped being true. + this->area_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + for (uint8_t i = 0; i < AREA_COUNT; i++) { + if (this->area_presence_[i] != nullptr) { + this->area_presence_[i]->publish_state(false); + } + } + const bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif +} + +void LD6002BComponent::clear_target_state_() { + // Nothing corrects any of this until the stream comes back. The slot table goes + // with it: slots key on cluster ids, which only track a person while reports are + // arriving, and the room can empty and refill across the gap -- so the next + // report starts from an empty table and fills slots in wire order, rather than + // handing one back to whoever last held that id. + for (uint8_t i = 0; i < MAX_TARGETS; i++) { +#ifdef USE_SENSOR + this->clear_target_slot_(i); + this->last_target_presence_[i] = false; +#endif + if (this->slot_occupied_[i]) { + this->slot_occupied_[i] = false; +#ifdef USE_BINARY_SENSOR + if (this->target_presence_[i] != nullptr) { + this->target_presence_[i]->publish_state(false); + } +#endif + } + } +#ifdef USE_SENSOR + if (this->last_target_count_ != 0xFFFFFFFF) { + if (this->target_count_sensor_ != nullptr) { + this->target_count_sensor_->publish_state(NAN); + } + this->last_target_count_ = 0xFFFFFFFF; + } +#endif + if (this->target_presence_any_) { + this->target_presence_any_ = false; +#ifdef USE_BINARY_SENSOR + bool presence = this->target_presence_any_ || this->area_presence_any_; + if (this->presence_binary_sensor_ != nullptr) { + this->presence_binary_sensor_->publish_state(presence); + } +#endif + this->update_work_mode_fallback_(); + } +} + +void LD6002BComponent::set_switch_state(SwitchType type, bool state) { + switch (type) { + case SwitchType::LOW_POWER: + this->low_power_enabled_ = state; + this->low_power_reported_ = true; + this->send_control_command_(state ? CMD_LOW_POWER_ON : CMD_LOW_POWER_OFF); + this->update_work_mode_fallback_(); + break; + case SwitchType::POINT_CLOUD: + this->point_cloud_enabled_ = state; + this->send_control_command_(state ? CMD_POINT_CLOUD_ON : CMD_POINT_CLOUD_OFF); +#ifdef USE_SENSOR + // The count only moves while the stream runs, so the last one would stand as + // a live reading. The dedup sentinel is cleared with it: the same count is + // new again when the stream comes back. + if (!state && this->point_count_sensor_ != nullptr && this->last_point_count_ != 0xFFFFFFFF) { + this->point_count_sensor_->publish_state(NAN); + this->last_point_count_ = 0xFFFFFFFF; + } +#endif + break; + case SwitchType::TARGET_DISPLAY: + this->target_display_enabled_ = state; + this->send_control_command_(state ? CMD_TARGET_DISPLAY_ON : CMD_TARGET_DISPLAY_OFF); + if (!state) { + // Every target entity is fed by the reports this just stopped. + this->clear_target_state_(); + } + break; + } +} + +void LD6002BComponent::press_button(ButtonType type) { + switch (type) { + case ButtonType::APPLY_AREA: + this->apply_area_config_(); + break; + case ButtonType::AUTO_INTERFERENCE: + this->send_control_command_(CMD_AUTO_INTERFERENCE); + // The module recomputes the interference areas without reporting them. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_AREAS: + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::CLEAR_INTERFERENCE: + this->send_control_command_(CMD_CLEAR_INTERFERENCE); + // The module rewrites the areas but does not report them, so ask for the new geometry the + // way the apply_area ack path does; the queue keeps it behind the command above. + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::RESET_DETECTION_AREA: + this->send_control_command_(CMD_RESET_DETECTION_AREA); + this->send_control_command_(CMD_GET_AREAS); + break; + case ButtonType::GET_DELAY: + this->send_control_command_(CMD_GET_DELAY); + break; + case ButtonType::GET_SENSITIVITY: + this->send_control_command_(CMD_GET_SENSITIVITY); + break; + case ButtonType::GET_TRIGGER_SPEED: + this->send_control_command_(CMD_GET_TRIGGER); + break; + case ButtonType::GET_Z_RANGE: + this->send_control_command_(CMD_GET_Z_RANGE); + break; + case ButtonType::GET_INSTALLATION: + this->send_control_command_(CMD_GET_INSTALLATION); + break; + case ButtonType::GET_LOW_POWER_MODE: + this->send_control_command_(CMD_GET_LOW_POWER); + break; + case ButtonType::GET_LOW_POWER_SLEEP_TIME: + this->send_control_command_(CMD_GET_LOW_POWER_SLEEP); + break; + case ButtonType::RESET_UNATTENDED: + this->send_control_command_(CMD_RESET_UNATTENDED); + break; + case ButtonType::WAKE: + this->wake_(); + break; + } +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h new file mode 100644 index 0000000000..bea3804312 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.h @@ -0,0 +1,503 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" +#include "esphome/core/gpio.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +#include +#include + +namespace esphome::ld6002b { + +static constexpr uint8_t MAX_TARGETS = 3; +static constexpr uint8_t AREA_COUNT = 4; +// Interference areas own ids 0..AREA_COUNT-1 and detection areas the next four, so +// this is the whole id space TYPE_SET_AREA accepts. +static constexpr uint8_t AREA_ID_COUNT = AREA_COUNT * 2; +static constexpr size_t DEFAULT_MAX_DATA_LEN = 1024; +static constexpr size_t DEFAULT_MAX_DATA_LEN_POINT_CLOUD = 4096; +// Largest protocol payload is TYPE_SET_AREA: int32 area id + 6 floats = 28 bytes. +static constexpr size_t CMD_MAX_DATA_LEN = 28; + +enum class NumberType : uint8_t { + HOLD_DELAY, + Z_MIN, + Z_MAX, + LOW_POWER_SLEEP, + AREA_X_MIN, + AREA_X_MAX, + AREA_Y_MIN, + AREA_Y_MAX, + AREA_Z_MIN, + AREA_Z_MAX, +}; + +enum class SelectType : uint8_t { + SENSITIVITY, + TRIGGER_SPEED, + INSTALLATION_MODE, + AREA_ID, +}; + +enum class SwitchType : uint8_t { + LOW_POWER, + POINT_CLOUD, + TARGET_DISPLAY, +}; + +enum class ButtonType : uint8_t { + APPLY_AREA, + AUTO_INTERFERENCE, + GET_AREAS, + CLEAR_INTERFERENCE, + RESET_DETECTION_AREA, + GET_DELAY, + GET_SENSITIVITY, + GET_TRIGGER_SPEED, + GET_Z_RANGE, + GET_INSTALLATION, + GET_LOW_POWER_MODE, + GET_LOW_POWER_SLEEP_TIME, + RESET_UNATTENDED, + WAKE, +}; + +#ifdef USE_SENSOR +struct TargetSensors { + sensor::Sensor *x{nullptr}; + sensor::Sensor *y{nullptr}; + sensor::Sensor *z{nullptr}; + sensor::Sensor *dop_idx{nullptr}; + sensor::Sensor *cluster_id{nullptr}; +}; + +struct AreaSensors { + sensor::Sensor *x_min{nullptr}; + sensor::Sensor *x_max{nullptr}; + sensor::Sensor *y_min{nullptr}; + sensor::Sensor *y_max{nullptr}; + sensor::Sensor *z_min{nullptr}; + sensor::Sensor *z_max{nullptr}; +}; +#endif + +struct AreaConfig { + float x_min{NAN}; + float x_max{NAN}; + float y_min{NAN}; + float y_max{NAN}; + float z_min{NAN}; + float z_max{NAN}; +}; + +struct VersionPref { + char value[20]; +}; + +class LD6002BComponent : public Component, public uart::UARTDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_wakeup_pin(GPIOPin *pin) { this->wakeup_pin_ = pin; } + void set_wakeup_pulse_ms(uint32_t ms) { this->wakeup_pulse_ms_ = ms; } + void set_auto_wake(bool enable) { this->auto_wake_ = enable; } + +#ifdef USE_SENSOR + void set_target_count_sensor(sensor::Sensor *sensor) { this->target_count_sensor_ = sensor; } + void set_point_count_sensor(sensor::Sensor *sensor) { this->point_count_sensor_ = sensor; } + + void set_target_x_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].x = sensor; + } + void set_target_y_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].y = sensor; + } + void set_target_z_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].z = sensor; + } + void set_target_dop_idx_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].dop_idx = sensor; + } + void set_target_cluster_id_sensor(uint8_t target, sensor::Sensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->targets_[target].cluster_id = sensor; + } + void set_interference_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_min = sensor; + } + void set_interference_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].x_max = sensor; + } + void set_interference_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_min = sensor; + } + void set_interference_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].y_max = sensor; + } + void set_interference_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_min = sensor; + } + void set_interference_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->interference_areas_[area].z_max = sensor; + } + + void set_detection_area_x_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_min = sensor; + } + void set_detection_area_x_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].x_max = sensor; + } + void set_detection_area_y_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_min = sensor; + } + void set_detection_area_y_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].y_max = sensor; + } + void set_detection_area_z_min_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_min = sensor; + } + void set_detection_area_z_max_sensor(uint8_t area, sensor::Sensor *sensor) { + if (area >= AREA_COUNT) + return; + this->detection_areas_[area].z_max = sensor; + } +#endif + +#ifdef USE_BINARY_SENSOR + void set_presence_binary_sensor(binary_sensor::BinarySensor *sensor) { this->presence_binary_sensor_ = sensor; } + void set_target_presence_binary_sensor(uint8_t target, binary_sensor::BinarySensor *sensor) { + if (target >= MAX_TARGETS) + return; + this->target_presence_[target] = sensor; + } + void set_area_presence_binary_sensor(uint8_t area, binary_sensor::BinarySensor *sensor) { + if (area >= AREA_COUNT) + return; + this->area_presence_[area] = sensor; + } +#endif + +#ifdef USE_TEXT_SENSOR + void set_work_mode_text_sensor(text_sensor::TextSensor *sensor) { this->work_mode_text_sensor_ = sensor; } + void set_ota_version_text_sensor(text_sensor::TextSensor *sensor) { this->ota_version_text_sensor_ = sensor; } +#endif + +#ifdef USE_NUMBER + void set_hold_delay_number(number::Number *number) { this->hold_delay_number_ = number; } + void set_z_min_number(number::Number *number) { this->z_min_number_ = number; } + void set_z_max_number(number::Number *number) { this->z_max_number_ = number; } + void set_low_power_sleep_number(number::Number *number) { this->low_power_sleep_number_ = number; } + + void set_area_x_min_number(number::Number *number) { this->area_x_min_number_ = number; } + void set_area_x_max_number(number::Number *number) { this->area_x_max_number_ = number; } + void set_area_y_min_number(number::Number *number) { this->area_y_min_number_ = number; } + void set_area_y_max_number(number::Number *number) { this->area_y_max_number_ = number; } + void set_area_z_min_number(number::Number *number) { this->area_z_min_number_ = number; } + void set_area_z_max_number(number::Number *number) { this->area_z_max_number_ = number; } +#endif + +#ifdef USE_SELECT + void set_sensitivity_select(select::Select *select) { this->sensitivity_select_ = select; } + void set_trigger_speed_select(select::Select *select) { this->trigger_speed_select_ = select; } + void set_installation_select(select::Select *select) { this->installation_select_ = select; } + void set_area_id_select(select::Select *select) { this->area_id_select_ = select; } +#endif + +#ifdef USE_SWITCH + void set_low_power_switch(switch_::Switch *sw) { this->low_power_switch_ = sw; } + void set_point_cloud_switch(switch_::Switch *sw) { this->point_cloud_switch_ = sw; } + void set_target_display_switch(switch_::Switch *sw) { this->target_display_switch_ = sw; } +#endif + + void set_number_value(NumberType type, float value); + void set_select_value(SelectType type, size_t index); + void set_switch_state(SwitchType type, bool state); + void press_button(ButtonType type); + + protected: + enum class ParseState : uint8_t { SOF, HEADER, HCK, DATA, DCK, DISCARD }; + + struct PendingCommand { + uint16_t type{0}; + uint8_t len{0}; + std::array data{}; + }; + + void parse_byte_(uint8_t byte); + void reset_parser_(); + void handle_frame_(uint16_t type, const uint8_t *data, uint16_t len); + void handle_target_report_(const uint8_t *data, uint16_t len); + void handle_point_cloud_(const uint8_t *data, uint16_t len); + void handle_area_presence_(const uint8_t *data, uint16_t len); + void handle_area_report_(bool interference, const uint8_t *data, uint16_t len); + void handle_delay_report_(const uint8_t *data, uint16_t len); + void handle_sensitivity_report_(const uint8_t *data, uint16_t len); + void handle_trigger_speed_report_(const uint8_t *data, uint16_t len); + void handle_z_range_report_(const uint8_t *data, uint16_t len); + void handle_installation_report_(const uint8_t *data, uint16_t len); + void handle_low_power_report_(const uint8_t *data, uint16_t len); + void handle_low_power_sleep_report_(const uint8_t *data, uint16_t len); + void handle_work_mode_report_(const uint8_t *data, uint16_t len); + void handle_version_report_(const uint8_t *data, uint16_t len); + void update_work_mode_fallback_(); + void publish_work_mode_(bool low_power); + // Drops every target-derived reading and the slot table they are indexed by. + void clear_target_state_(); + void clear_area_presence_(); + void restore_deferred_edits_(); + void publish_area_numbers_(); +#ifdef USE_SENSOR + void clear_target_slot_(uint8_t index); +#endif +#ifdef USE_NUMBER + void publish_number_clamped_(number::Number *number, float value); +#endif + void update_area_numbers_(const AreaConfig &area); + void update_area_numbers_for_id_(uint8_t area_id); + bool queue_area_config_(uint8_t area_id, const AreaConfig &desired); + void try_apply_pending_area_(bool reported_interference); + void init_area_id_pref_(); + void save_area_id_pref_(uint8_t value); + void init_version_pref_(); + void save_version_pref_(const char *value); + + // Returns whether the command was queued: it is dropped, with a log line, when + // the payload is too long or the ring is full. + bool queue_command_(uint16_t type, const uint8_t *data, uint8_t len); + void process_command_queue_(); + void send_command_(uint16_t type, const uint8_t *data, uint8_t len); + void send_command_internal_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); + // Returns whether the command reached the queue; see queue_command_. + bool send_control_command_(uint32_t command); + void send_z_range_(); + void apply_area_config_(); + void wake_(); + + static uint16_t read_u16_be(const uint8_t *data); + static uint32_t read_u32_le(const uint8_t *data); + static int32_t read_int32_le(const uint8_t *data); + static float read_f32_le(const uint8_t *data); + static void write_u32_le(uint8_t *data, uint32_t value); + static void write_int32_le(uint8_t *data, int32_t value); + static void write_f32_le(uint8_t *data, float value); + +#ifdef USE_SENSOR + std::array targets_{}; + sensor::Sensor *target_count_sensor_{nullptr}; + sensor::Sensor *point_count_sensor_{nullptr}; + std::array interference_areas_{}; + std::array detection_areas_{}; +#endif +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *presence_binary_sensor_{nullptr}; + std::array target_presence_{}; + std::array area_presence_{}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *work_mode_text_sensor_{nullptr}; + text_sensor::TextSensor *ota_version_text_sensor_{nullptr}; + ESPPreferenceObject version_pref_{}; + bool version_pref_initialized_{false}; +#endif +#ifdef USE_NUMBER + number::Number *hold_delay_number_{nullptr}; + number::Number *z_min_number_{nullptr}; + number::Number *z_max_number_{nullptr}; + number::Number *low_power_sleep_number_{nullptr}; + + number::Number *area_x_min_number_{nullptr}; + number::Number *area_x_max_number_{nullptr}; + number::Number *area_y_min_number_{nullptr}; + number::Number *area_y_max_number_{nullptr}; + number::Number *area_z_min_number_{nullptr}; + number::Number *area_z_max_number_{nullptr}; +#endif +#ifdef USE_SELECT + select::Select *sensitivity_select_{nullptr}; + select::Select *trigger_speed_select_{nullptr}; + select::Select *installation_select_{nullptr}; + select::Select *area_id_select_{nullptr}; + ESPPreferenceObject area_id_pref_{}; + bool area_id_pref_initialized_{false}; +#endif +#ifdef USE_SWITCH + switch_::Switch *low_power_switch_{nullptr}; + switch_::Switch *point_cloud_switch_{nullptr}; + switch_::Switch *target_display_switch_{nullptr}; +#endif + + GPIOPin *wakeup_pin_{nullptr}; + uint32_t wakeup_pulse_ms_{50}; + bool auto_wake_{true}; + + ParseState parse_state_{ParseState::SOF}; + uint8_t header_pos_{0}; + uint8_t header_xor_{0}; + uint16_t data_len_{0}; + uint16_t frame_type_{0}; + uint16_t frame_id_{0}; + uint16_t data_pos_{0}; + uint8_t data_xor_{0}; + uint32_t discard_remaining_{0}; + bool frame_oversize_{false}; + size_t max_data_len_{0}; + uint8_t *data_buf_{nullptr}; + uint16_t next_frame_id_{0}; + + // Sized for the two bursts that reach it, both counted as what is still queued + // once the first command is dequeued: boot leaves 11 with every platform + // configured, and pressing all fourteen buttons before an ack lands leaves 15. + // Neither overflowed 16, but one free slot is not headroom, and overflowing is a + // dropped command with only a log line to show for it. Costs 256 bytes more per + // configured instance, and this component is MULTI_CONF. + static constexpr uint8_t CMD_QUEUE_SIZE = 24; + static constexpr uint32_t CMD_ACK_TIMEOUT_MS = 300; + // A sleeping module consumes the first frame to wake and answers only the one after it. + static constexpr uint32_t CMD_FIRST_ACK_TIMEOUT_MS = 600; + // How long the module stays awake after any frame, and so still answers the next one. + static constexpr uint32_t MODULE_AWAKE_MS = 10000; + static constexpr uint8_t CMD_MAX_RETRIES = 3; + // Named so a repeated press replaces its own pending timeout instead of stacking + // another, and so the command path can cancel it when it takes the pin over. + static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; + // Named so a burst of writes collapses to one read once they settle, rather than + // one read per write. + static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; + // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. + static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; + + std::array cmd_queue_{}; + uint8_t cmd_head_{0}; + uint8_t cmd_tail_{0}; + uint8_t cmd_count_{0}; + bool command_active_{false}; + bool command_sent_{false}; + PendingCommand active_command_{}; + // Frame a pending wake pulse will write, snapshotted because active_command_ may move on first. + std::array wake_scratch_{}; + bool wake_pulse_pending_{false}; + uint8_t retries_left_{0}; + uint32_t last_send_ms_{0}; + // Last frame seen in either direction; any traffic keeps the module awake. + uint32_t last_traffic_ms_{0}; + // Frames transmitted for the command in flight, including retries; drives the retry budget. + uint8_t attempts_sent_{0}; + // Subset of those the module can actually answer: a frame that woke it is consumed, not replied to. + uint8_t acks_expected_{0}; + // ACKs still owed for superseded attempts; they carry no id, only their arrival order. + uint16_t stale_ack_type_{0}; + uint8_t stale_ack_count_{0}; + // When that debt was booked, so a debt no reply can still settle expires instead of eating a live ACK. + uint32_t stale_ack_ms_{0}; + // Bumped whenever the active command changes, so a deferred send can tell it was retired. + uint8_t send_generation_{0}; + + float z_min_{NAN}; + float z_max_{NAN}; + float area_x_min_{NAN}; + float area_x_max_{NAN}; + float area_y_min_{NAN}; + float area_y_max_{NAN}; + float area_z_min_{NAN}; + float area_z_max_{NAN}; + // What the user has typed and not yet applied; NaN per axis means "nothing of + // mine here, take the module's value". Same sentinel shape as + // pending_area_updates_. Exactly two things empty it: the area_id select moving + // to another area, and an apply that was accepted. A write the bounds guard + // refused leaves it alone, and a deferred apply that had to be dropped hands its + // staged values back here -- but only while the user is still on the area they + // were staged for. Either way the values stay the user's to fix. + AreaConfig area_edits_{}; + std::array interference_area_values_{}; + std::array detection_area_values_{}; + uint8_t area_id_{0xFF}; + bool area_id_set_{false}; + + // Which person owns each target_N slot, so a slot survives the module re-sorting its array. + std::array slot_cluster_{}; + std::array slot_occupied_{}; + + bool target_presence_any_{false}; + // What the switches and setup asked the module for, which is not the same as + // what it is doing yet: a stream keeps sending until it acts on the command. + // The report handlers read these and drop anything a stopped stream still emits. + bool target_display_enabled_{false}; + bool point_cloud_enabled_{false}; + bool area_presence_any_{false}; + bool area_write_in_flight_{false}; + bool work_mode_reported_{false}; + bool low_power_enabled_{false}; + bool low_power_reported_{false}; + bool deferred_apply_pending_{false}; + uint8_t pending_area_id_{0xFF}; + AreaConfig pending_area_updates_{}; + bool last_work_mode_valid_{false}; + bool last_work_mode_low_power_{false}; + +#ifdef USE_SENSOR + std::array last_target_presence_{}; // one-shot NAN clear for target sensors + // A cluster id names a person, so like the counts it is published on change, not per frame. + std::array last_cluster_id_{}; + std::array last_cluster_id_valid_{}; + uint32_t last_target_count_{0xFFFFFFFF}; + uint32_t last_point_count_{0xFFFFFFFF}; +#endif +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py new file mode 100644 index 0000000000..236b049f53 --- /dev/null +++ b/esphome/components/ld6002b/number/__init__.py @@ -0,0 +1,179 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import ( + CONF_AREA_ID, + CONF_BUTTON, + DEVICE_CLASS_DISTANCE, + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_CONFIG, + UNIT_METER, + UNIT_MILLISECOND, + UNIT_SECOND, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_APPLY_AREA, + CONF_AREA_CONFIG, + CONF_HOLD_DELAY, + CONF_LD6002B_ID, + CONF_LOW_POWER_SLEEP_TIME, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BNumber = ld6002b_ns.class_("LD6002BNumber", number.Number) +NumberType = ld6002b_ns.enum("NumberType", is_class=True) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_HOLD_DELAY): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_SECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_LOW_POWER_SLEEP_TIME): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_MILLISECOND, + device_class=DEVICE_CLASS_DURATION, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_AREA_CONFIG): cv.Schema( + { + cv.Optional(KEY_X_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_X_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(KEY_Y_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MIN): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_Z_MAX): number.number_schema( + LD6002BNumber, + unit_of_measurement=UNIT_METER, + device_class=DEVICE_CLASS_DISTANCE, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } + ), + } +) + + +def final_validate(config: ConfigType) -> None: + if config.get(CONF_AREA_CONFIG) is None: + return + + full_config = fv.full_config.get() + hub_id = config[CONF_LD6002B_ID] + + has_apply_area = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None + for entry in full_config.get(CONF_BUTTON, []) + ) + if not has_apply_area: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires button.apply_area for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + has_area_id_select = any( + entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None + for entry in full_config.get("select", []) + ) + if not has_area_id_select: + raise cv.Invalid( + f"{CONF_AREA_CONFIG} requires select.area_id for the same ld6002b instance", + path=[CONF_AREA_CONFIG], + ) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, number_type, setter, min_value, max_value, step in ( + (CONF_HOLD_DELAY, NumberType.HOLD_DELAY, "set_hold_delay_number", 0, 65535, 1), + (CONF_Z_MIN, NumberType.Z_MIN, "set_z_min_number", -10, 10, 0.1), + (CONF_Z_MAX, NumberType.Z_MAX, "set_z_max_number", -10, 10, 0.1), + # 0x0205 carries a uint32 of milliseconds; the vendor documents 500 ms as + # the default and no upper bound, so the range ends at a minute rather + # than at a default the module is free to be sleeping past. + ( + CONF_LOW_POWER_SLEEP_TIME, + NumberType.LOW_POWER_SLEEP, + "set_low_power_sleep_number", + 0, + 60000, + 100, + ), + ): + if conf := config.get(key): + n = await number.new_number( + conf, number_type, min_value=min_value, max_value=max_value, step=step + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) + + if area_config := config.get(CONF_AREA_CONFIG): + for key, number_type, setter in ( + (KEY_X_MIN, NumberType.AREA_X_MIN, "set_area_x_min_number"), + (KEY_X_MAX, NumberType.AREA_X_MAX, "set_area_x_max_number"), + (KEY_Y_MIN, NumberType.AREA_Y_MIN, "set_area_y_min_number"), + (KEY_Y_MAX, NumberType.AREA_Y_MAX, "set_area_y_max_number"), + (CONF_Z_MIN, NumberType.AREA_Z_MIN, "set_area_z_min_number"), + (CONF_Z_MAX, NumberType.AREA_Z_MAX, "set_area_z_max_number"), + ): + if conf := area_config.get(key): + n = await number.new_number( + conf, number_type, min_value=-10, max_value=10, step=0.1 + ) + await cg.register_parented(n, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(n)) diff --git a/esphome/components/ld6002b/number/ld6002b_number.cpp b/esphome/components/ld6002b/number/ld6002b_number.cpp new file mode 100644 index 0000000000..b0b1b6f72b --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_number.h" + +namespace esphome::ld6002b { + +void LD6002BNumber::control(float value) { + this->publish_state(value); + this->parent_->set_number_value(this->type_, value); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/number/ld6002b_number.h b/esphome/components/ld6002b/number/ld6002b_number.h new file mode 100644 index 0000000000..3101b4d3cd --- /dev/null +++ b/esphome/components/ld6002b/number/ld6002b_number.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/number/number.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BNumber : public number::Number, public Parented { + public: + explicit LD6002BNumber(NumberType type) : type_(type) {} + + protected: + void control(float value) override; + + NumberType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select/__init__.py new file mode 100644 index 0000000000..7f5e528b84 --- /dev/null +++ b/esphome/components/ld6002b/select/__init__.py @@ -0,0 +1,75 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_AREA_ID, CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import LD6002BComponent, ld6002b_ns +from ..const import CONF_INSTALLATION_MODE, CONF_LD6002B_ID, CONF_TRIGGER_SPEED + +DEPENDENCIES = ["ld6002b"] + +LD6002BSelect = ld6002b_ns.class_("LD6002BSelect", select.Select) +SelectType = ld6002b_ns.enum("SelectType", is_class=True) + +AREA_ID_OPTIONS = [ + "interference_area_0", + "interference_area_1", + "interference_area_2", + "interference_area_3", + "detection_area_0", + "detection_area_1", + "detection_area_2", + "detection_area_3", +] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_SENSITIVITY): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_TRIGGER_SPEED): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_INSTALLATION_MODE): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_AREA_ID): select.select_schema( + LD6002BSelect, entity_category=ENTITY_CATEGORY_CONFIG + ), + } +) + + +SELECT_MAP = ( + ( + CONF_SENSITIVITY, + SelectType.SENSITIVITY, + "set_sensitivity_select", + ["low", "medium", "high"], + ), + ( + CONF_TRIGGER_SPEED, + SelectType.TRIGGER_SPEED, + "set_trigger_speed_select", + ["slow", "medium", "fast"], + ), + ( + CONF_INSTALLATION_MODE, + SelectType.INSTALLATION_MODE, + "set_installation_select", + ["top", "side"], + ), + (CONF_AREA_ID, SelectType.AREA_ID, "set_area_id_select", AREA_ID_OPTIONS), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, select_type, setter, options in SELECT_MAP: + if conf := config.get(key): + s = await select.new_select(conf, select_type, options=options) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/select/ld6002b_select.cpp b/esphome/components/ld6002b/select/ld6002b_select.cpp new file mode 100644 index 0000000000..a6b524a665 --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_select.h" + +namespace esphome::ld6002b { + +void LD6002BSelect::control(size_t index) { + this->publish_state(index); + this->parent_->set_select_value(this->type_, index); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/select/ld6002b_select.h b/esphome/components/ld6002b/select/ld6002b_select.h new file mode 100644 index 0000000000..f380089a1e --- /dev/null +++ b/esphome/components/ld6002b/select/ld6002b_select.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSelect : public select::Select, public Parented { + public: + explicit LD6002BSelect(SelectType type) : type_(type) {} + + protected: + void control(size_t index) override; + + SelectType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py new file mode 100644 index 0000000000..cceefb3837 --- /dev/null +++ b/esphome/components/ld6002b/sensor.py @@ -0,0 +1,189 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + STATE_CLASS_MEASUREMENT, + UNIT_METER, +) +from esphome.types import ConfigType + +from . import LD6002BComponent +from .const import ( + AREA_COUNT, + CONF_CLUSTER_ID, + CONF_DOPPLER_INDEX, + CONF_LD6002B_ID, + CONF_POINT_COUNT, + CONF_Z, + CONF_Z_MAX, + CONF_Z_MIN, + KEY_X_MAX, + KEY_X_MIN, + KEY_Y_MAX, + KEY_Y_MIN, + MAX_TARGETS, +) + +DEPENDENCIES = ["ld6002b"] + +# The ld2450 defaults for a streamed value: hold the last reading for a second so a +# dropped frame does not read as absence, then rate-limit what reaches the frontend. +_VALUE_SENSOR_FILTERS = [ + { + "timeout": { + "timeout": cv.TimePeriod(milliseconds=1000), + "value": "last", + } + }, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, +] + +TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( + accuracy_decimals=0, + filters=_VALUE_SENSOR_FILTERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( + accuracy_decimals=0, + ), + } +) + +AREA_SCHEMA = cv.Schema( + { + cv.Optional(KEY_X_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_X_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(KEY_Y_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MIN): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_Z_MAX): sensor.sensor_schema( + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + +# (config key, C++ setter axis) for the six bounds every area sensor block carries. +_AREA_AXES = ( + (KEY_X_MIN, "x_min"), + (KEY_X_MAX, "x_max"), + (KEY_Y_MIN, "y_min"), + (KEY_Y_MAX, "y_max"), + (CONF_Z_MIN, "z_min"), + (CONF_Z_MAX, "z_max"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POINT_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend({cv.Optional(f"target_{i + 1}"): TARGET_SCHEMA for i in range(MAX_TARGETS)}) + .extend( + {cv.Optional(f"interference_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) + .extend( + {cv.Optional(f"detection_area_{i}"): AREA_SCHEMA for i in range(AREA_COUNT)} + ) +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + if target_count_config := config.get(CONF_TARGET_COUNT): + sens = await sensor.new_sensor(target_count_config) + cg.add(hub.set_target_count_sensor(sens)) + + if point_count_config := config.get(CONF_POINT_COUNT): + sens = await sensor.new_sensor(point_count_config) + cg.add(hub.set_point_count_sensor(sens)) + + for i in range(MAX_TARGETS): + if target_config := config.get(f"target_{i + 1}"): + if x_config := target_config.get(CONF_X): + sens = await sensor.new_sensor(x_config) + cg.add(hub.set_target_x_sensor(i, sens)) + if y_config := target_config.get(CONF_Y): + sens = await sensor.new_sensor(y_config) + cg.add(hub.set_target_y_sensor(i, sens)) + if z_config := target_config.get(CONF_Z): + sens = await sensor.new_sensor(z_config) + cg.add(hub.set_target_z_sensor(i, sens)) + if doppler_index_config := target_config.get(CONF_DOPPLER_INDEX): + sens = await sensor.new_sensor(doppler_index_config) + cg.add(hub.set_target_dop_idx_sensor(i, sens)) + if cluster_id_config := target_config.get(CONF_CLUSTER_ID): + sens = await sensor.new_sensor(cluster_id_config) + cg.add(hub.set_target_cluster_id_sensor(i, sens)) + + for kind in ("interference", "detection"): + for i in range(AREA_COUNT): + if area_config := config.get(f"{kind}_area_{i}"): + for key, axis in _AREA_AXES: + if axis_config := area_config.get(key): + sens = await sensor.new_sensor(axis_config) + cg.add(getattr(hub, f"set_{kind}_area_{axis}_sensor")(i, sens)) diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch/__init__.py new file mode 100644 index 0000000000..a414308b65 --- /dev/null +++ b/esphome/components/ld6002b/switch/__init__.py @@ -0,0 +1,61 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import LD6002BComponent, ld6002b_ns +from ..const import ( + CONF_LD6002B_ID, + CONF_LOW_POWER, + CONF_POINT_CLOUD, + CONF_TARGET_DISPLAY, +) + +DEPENDENCIES = ["ld6002b"] + +LD6002BSwitch = ld6002b_ns.class_("LD6002BSwitch", switch.Switch) +SwitchType = ld6002b_ns.enum("SwitchType", is_class=True) + +# None of these three carry an inversion. They name what the module is doing, not +# how something is wired to it, so an inverted one would only report the opposite +# of the truth -- and the boot restore, which applies a state nothing reports back, +# is where that would be hardest to spot. +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_LOW_POWER): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_POINT_CLOUD): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_TARGET_DISPLAY): switch.switch_schema( + LD6002BSwitch, + block_inverted=True, + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_ON", + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + + for key, switch_type, setter in ( + (CONF_LOW_POWER, SwitchType.LOW_POWER, "set_low_power_switch"), + (CONF_POINT_CLOUD, SwitchType.POINT_CLOUD, "set_point_cloud_switch"), + (CONF_TARGET_DISPLAY, SwitchType.TARGET_DISPLAY, "set_target_display_switch"), + ): + if conf := config.get(key): + s = await switch.new_switch(conf, switch_type) + await cg.register_parented(s, config[CONF_LD6002B_ID]) + cg.add(getattr(hub, setter)(s)) diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.cpp b/esphome/components/ld6002b/switch/ld6002b_switch.cpp new file mode 100644 index 0000000000..7542f7b1ff --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.cpp @@ -0,0 +1,10 @@ +#include "ld6002b_switch.h" + +namespace esphome::ld6002b { + +void LD6002BSwitch::write_state(bool state) { + this->parent_->set_switch_state(this->type_, state); + this->publish_state(state); +} + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/switch/ld6002b_switch.h b/esphome/components/ld6002b/switch/ld6002b_switch.h new file mode 100644 index 0000000000..44773f802f --- /dev/null +++ b/esphome/components/ld6002b/switch/ld6002b_switch.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/switch/switch.h" +#include "../ld6002b.h" + +namespace esphome::ld6002b { + +class LD6002BSwitch : public switch_::Switch, public Parented { + public: + explicit LD6002BSwitch(SwitchType type) : type_(type) {} + + protected: + void write_state(bool state) override; + + SwitchType type_; +}; + +} // namespace esphome::ld6002b diff --git a/esphome/components/ld6002b/text_sensor.py b/esphome/components/ld6002b/text_sensor.py new file mode 100644 index 0000000000..0e8e2e80e7 --- /dev/null +++ b/esphome/components/ld6002b/text_sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import LD6002BComponent +from .const import CONF_LD6002B_ID, CONF_OTA_VERSION, CONF_WORK_MODE + +DEPENDENCIES = ["ld6002b"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD6002B_ID): cv.use_id(LD6002BComponent), + cv.Optional(CONF_WORK_MODE): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + cv.Optional(CONF_OTA_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD6002B_ID]) + if work_mode_config := config.get(CONF_WORK_MODE): + sens = await text_sensor.new_text_sensor(work_mode_config) + cg.add(hub.set_work_mode_text_sensor(sens)) + if ota_config := config.get(CONF_OTA_VERSION): + sens = await text_sensor.new_text_sensor(ota_config) + cg.add(hub.set_ota_version_text_sensor(sens)) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 5b7b6c7ee6..62833a7649 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -53,7 +53,11 @@ static_assert( "re-evaluate for this target"); static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + auto *hw = LEDC_LL_GET_HW(0); +#else auto *hw = LEDC_LL_GET_HW(); +#endif return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; } #endif @@ -161,13 +165,18 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::setup() { if (!ledc_peripheral_reset_done) { ESP_LOGV(TAG, "Resetting LEDC peripheral to clear stale state after reboot"); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + // Skip under clang-tidy: the inlined HAL MMIO writes trip clang-analyzer-core.FixedAddressDereference +#if !defined(CLANG_TIDY) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + PERIPH_RCC_ATOMIC() { ledc_ll_reset_register(0); } +#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) PERIPH_RCC_ATOMIC() { ledc_ll_enable_reset_reg(true); ledc_ll_enable_reset_reg(false); } #else periph_module_reset(PERIPH_LEDC_MODULE); +#endif #endif ledc_peripheral_reset_done = true; } 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/ledc/output.py b/esphome/components/ledc/output.py index 95df1fba23..e5e7c3dcbe 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -1,6 +1,9 @@ +from typing import Any + from esphome import automation, pins import esphome.codegen as cg from esphome.components import output +from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, @@ -9,20 +12,23 @@ from esphome.const import ( CONF_PHASE_ANGLE, CONF_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] -def calc_max_frequency(bit_depth): +def calc_max_frequency(bit_depth: int) -> float: return 80e6 / (2**bit_depth) -def calc_min_frequency(bit_depth): +def calc_min_frequency(bit_depth: int) -> float: max_div_num = ((2**20) - 1) / 256.0 return 80e6 / (max_div_num * (2**bit_depth)) -def validate_frequency(value): +def validate_frequency(value: Any) -> float: value = cv.frequency(value) min_freq = calc_min_frequency(20) max_freq = calc_max_frequency(1) @@ -56,7 +62,10 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + # Re-enable the LEDC driver (excluded by default to save compile time) + include_builtin_idf_component("esp_driver_ledc") + gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -79,7 +88,12 @@ async def to_code(config): ), synchronous=True, ) -async def ledc_set_frequency_to_code(config, action_id, template_arg, args): +async def ledc_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 40fb773784..50dc787799 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -26,9 +26,10 @@ from esphome.const import ( from esphome.core import CORE from esphome.core.config import BOARD_MAX_LENGTH from esphome.helpers import copy_file_if_changed +from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON -from . import gpio # noqa +from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, CONF_GPIO_RECOVER, @@ -76,12 +77,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,16 +174,17 @@ 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. """ + # No recorded firmware path means nothing was built; no downloads. + if storage_json.firmware_bin_path is None: + return [] types = [ { "title": "UF2 package (recommended)", @@ -213,14 +230,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 +276,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) ), @@ -280,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All( _check_debug_order, ) -CONFIG_SCHEMA = cv.All(_notify_old_style) +CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny")) BASE_SCHEMA = cv.Schema( { @@ -294,6 +314,7 @@ BASE_SCHEMA = cv.Schema( ) BASE_SCHEMA.add_extra(_detect_variant) +BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny")) BASE_SCHEMA.add_extra(_update_core_data) @@ -444,6 +465,8 @@ async def component_to_code(config): # setup board config cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_LIBRETINY") + # FlashDB finds stored preferences by key, so preference key migration is possible + cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag(f"-DUSE_{config[CONF_COMPONENT_ID].upper()}") cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -490,6 +513,7 @@ async def component_to_code(config): # it for project source files only. GCC uses the last -O flag. build_src_flags += " -Os" cg.add_platformio_option("build_src_flags", build_src_flags) + cg.add_platformio_option("extra_scripts", ["pre:ccache.py"]) # IRAM_ATTR is a no-op on BK72xx (SDK masks FIQ+IRQ around flash ops). # On other families, patch_linker.py routes .sram.text into the right # RAM-executable output section and prints a post-link placement summary. @@ -513,13 +537,13 @@ async def component_to_code(config): # apply LibreTiny options from framework: block # setup LT logger to work nicely with ESPHome logger - lt_options = dict( - LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL], - LT_LOGGER_CALLER=0, - LT_LOGGER_TASK=0, - LT_LOGGER_COLOR=1, - LT_USE_TIME=1, - ) + lt_options = { + "LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL], + "LT_LOGGER_CALLER": 0, + "LT_LOGGER_TASK": 0, + "LT_LOGGER_COLOR": 1, + "LT_USE_TIME": 1, + } # enable/disable per-module debugging for module in framework[CONF_DEBUG]: if module == "NONE": @@ -564,8 +588,13 @@ async def component_to_code(config): cg.add_platformio_option("custom_fw_name", "esphome") cg.add_platformio_option("custom_fw_version", __version__) - # Apply chip-specific SDK options to save RAM/Flash - if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238): + # Apply chip-specific SDK options to save RAM/Flash. + # Skipped when bk72xx_ble is configured: add_platformio_option APPENDS list + # values (it never replaces), so emitting the disable here as well would put + # both CFG_SUPPORT_BLE=0 and =1 into the generated sys_config.h and rely on + # last-wins emission order. Skipping keeps it a single unambiguous define. + ble_requested = "bk72xx_ble" in CORE.config + if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238) and not ble_requested: cg.add_platformio_option( "custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS ) @@ -589,3 +618,4 @@ def copy_files() -> None: patch_linker_file, CORE.relative_build_path("patch_linker.py"), ) + copy_ccache_script() diff --git a/esphome/components/libretiny/generate_components.py b/esphome/components/libretiny/generate_components.py index d5437895a6..4997878657 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -1,7 +1,7 @@ # Copyright (c) Kuba Szczodrzyński 2023-06-01. # pylint: skip-file -# flake8: noqa +# ruff: noqa: C408, I001 import json import re @@ -65,6 +65,10 @@ def _set_core_data(config): return config +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). CONFIG_SCHEMA = {SCHEMA} PIN_SCHEMA = {PIN_SCHEMA} @@ -117,7 +121,7 @@ VAR_GPIO_PIN = "validate_pin" VAR_GPIO_USAGE = "validate_usage" # lines for code snippets -SCHEMA_BASE = "libretiny.BASE_SCHEMA" +SCHEMA_BASE = "libretiny.BASE_SCHEMA.extend({})" SCHEMA_EXTRA = f"libretiny.BASE_SCHEMA.extend({VAR_SCHEMA})" PIN_SCHEMA_BASE = "libretiny.gpio.BASE_PIN_SCHEMA" PIN_SCHEMA_EXTRA = f"libretiny.BASE_PIN_SCHEMA.extend({VAR_PIN_SCHEMA})" @@ -313,8 +317,12 @@ def write_const( # build component constants comp_str = "\n".join(f'COMPONENT_{f} = "{f.lower()}"' for f in components) # replace the 2nd regex group only - repl = lambda m: m.group(1) + comp_str + m.group(3) - code = re.sub(comp_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + comp_regex, + lambda m: m.group(1) + comp_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # regex for finding the family list block fam_regex = r"(# FAMILIES.+?\n)(.*?)(\n# FAMILIES)" @@ -337,8 +345,12 @@ def write_const( ] var_str = "\n".join(fam_lines) # replace the 2nd regex group only - repl = lambda m: m.group(1) + var_str + m.group(3) - code = re.sub(fam_regex, repl, code, flags=re.DOTALL | re.MULTILINE) + code = re.sub( + fam_regex, + lambda m: m.group(1) + var_str + m.group(3), + code, + flags=re.DOTALL | re.MULTILINE, + ) # format with black code = format_str(code, mode=FileMode()) @@ -351,7 +363,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/hal.cpp b/esphome/components/libretiny/hal.cpp index 67e902024d..01b276005d 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -44,7 +44,7 @@ void arch_init() { void arch_restart() { lt_reboot(); - while (1) { + while (true) { } } diff --git a/esphome/components/libretiny/hal.h b/esphome/components/libretiny/hal.h index 9c512504b7..48b94a5214 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -11,11 +11,19 @@ #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an -// ISR even while flash is busy (XIP stall, OTA, logger flash write). -// Each family uses a section its stock linker already routes to RAM: -// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the -// exception: its stock linker has no matching glob, so patch_linker.py -// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link. +// ISR even while flash is busy (XIP stall, OTA, logger flash write). All +// LibreTiny families that need it share the same .sram.text input section +// name; how that section is routed into RAM differs per family: +// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the +// top of .ram_image2.data (which IS in ltchiptool's +// sections_ram). The stock linker has KEEP(*(.image2.ram.text*)) +// in .ram_image2.text but that output section is NOT in +// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed +// there is dropped from the flashed binary. +// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into +// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors)) +// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR. // // BK72xx (all variants) are left as a no-op: their SDK wraps flash // operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for @@ -26,13 +34,7 @@ // layer. #if defined(USE_BK72XX) #define IRAM_ATTR -#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) -// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM). -#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text"))) #else -// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. -// LN882H: patch_linker.py.script injects *(.sram.text*) into -// .flash_copysection (> RAM0 AT> FLASH). #define IRAM_ATTR __attribute__((noinline, section(".sram.text"))) #endif #define PROGMEM @@ -42,6 +44,7 @@ // it is callable from Thumb code via interworking. The MRS CPSR instruction // is ARM-only and user code here may be built in Thumb, so in_isr_context() // defers to this port helper on BK72xx instead of reading CPSR inline. +// NOLINTNEXTLINE(readability-redundant-declaration) extern "C" uint32_t platform_is_in_interrupt_context(void); #endif @@ -57,9 +60,11 @@ extern "C" void delayMicroseconds(unsigned int us); // Forward decls from libretiny's family for the inline arch_* // wrappers below. Pulling the full header would drag in the rest of the // LibreTiny C API. +// NOLINTBEGIN(readability-redundant-declaration) extern "C" void lt_wdt_feed(void); extern "C" uint32_t lt_cpu_get_cycle_count(void); extern "C" uint32_t lt_cpu_get_freq(void); +// NOLINTEND(readability-redundant-declaration) namespace esphome::libretiny {} diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index c01661b3a6..0ab064e3e1 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -6,21 +6,21 @@ namespace esphome::libretiny { -static const char *const TAG = "lt.component"; +static const char *const TAG = "libretiny"; void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, "LibreTiny:\n" " Version: %s\n" " Loglevel: %u", - LT_BANNER_STR + 10, LT_LOGLEVEL); + <_BANNER_STR[10], LT_LOGLEVEL); #if defined(__OPTIMIZE_SIZE__) && __OPTIMIZE_LEVEL__ > 0 && __OPTIMIZE_LEVEL__ <= 3 ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { - this->version_->publish_state(LT_BANNER_STR + 10); + this->version_->publish_state(<_BANNER_STR[10]); } #endif // USE_TEXT_SENSOR } 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/patch_linker.py.script b/esphome/components/libretiny/patch_linker.py.script index 282a31d3f2..dfeaaa57d1 100644 --- a/esphome/components/libretiny/patch_linker.py.script +++ b/esphome/components/libretiny/patch_linker.py.script @@ -6,14 +6,22 @@ import re import subprocess # ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family -# section routed into RAM-executable memory (see esphome/core/hal.h). +# section routed into RAM-executable memory (see esphome/core/hal.h). The +# input section name is always .sram.text; only the output section it lands +# in differs per family. # # This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK # masks FIQ+IRQ around flash writes). On the remaining families: -# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it. -# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it. +# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text. +# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text, +# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list +# .ram_image2.text in sections_ram, so code there is silently dropped from +# the flashed image. Inject KEEP(*(.sram.text*)) at the top of +# .ram_image2.data (which IS extracted) instead. # - LN882H: stock linker has no glob for ".sram.text", so we inject -# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH). +# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH) +# immediately after KEEP(*(.vectors)), so the vector table stays at +# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment. # # All families also get a post-link summary showing where IRAM_ATTR landed. @@ -27,7 +35,25 @@ _KEEP_LINE = ( "__esphome_sram_text_end = .; " + _MARKER + "\n" ) -_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)") +# Inject after KEEP(*(.vectors)) so the vector table stays at +# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte- +# aligned address; injecting before the vectors would push them to an +# unaligned offset and mis-route every IRQ handler. +_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)") +# Inject at the top of .ram_image2.data, before __data_start__ so our code +# does not fall inside the data range markers. .ram_image2.data is one of the +# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is +# executable. AmbZ has no C runtime .data copy loop (the bootloader loads +# image2 into BD_RAM whole) so the inline code is not clobbered after boot. +# +# The regex is intentionally strict (no attribute / ALIGN between the section +# name and the opening brace, brace on its own line). If a future AmbZ SDK +# linker template changes this format, _pre_link raises RuntimeError on the +# unpatched .ld file(s), and the RTL8710B CI compile job in +# tests/test_build_components fails on the PR, surfacing the mismatch loudly +# rather than silently shipping a binary with IRAM_ATTR code dropped from +# one or both OTA slots. +_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)") def _detect(env): @@ -56,7 +82,7 @@ KNOWN_VARIANTS = frozenset({ def _inject_keep(host_section): - """Return a patcher that injects _KEEP_LINE at the top of `host_section`.""" + """Return a patcher that injects _KEEP_LINE after `host_section` match.""" def patch(content): if _MARKER in content: return content @@ -65,12 +91,11 @@ def _inject_keep(host_section): # Variants not listed here intentionally have no .ld patcher: -# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker -# already routes into .ram_image2.text (> BD_RAM). -# - RTL8720C: stock linker already consumes *(.sram.text*). +# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text. # - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op. _PATCHERS_BY_VARIANT = { "LN882H": (_inject_keep(_LN_COPY),), + "RTL8710B": (_inject_keep(_AMBZ_DATA),), } @@ -81,13 +106,14 @@ def _patchers_for(variant): def _pre_link(target, source, env): build_dir = env.subst("$BUILD_DIR") ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")] - patched = 0 + patched = [] + unpatched = [] for name in ld_files: path = os.path.join(build_dir, name) with open(path, "r", encoding="utf-8") as fh: original = fh.read() if _MARKER in original: - patched += 1 + patched.append(name) continue content = original for fn in _patchers: @@ -96,7 +122,9 @@ def _pre_link(target, source, env): with open(path, "w", encoding="utf-8") as fh: fh.write(content) print("ESPHome: patched {} for IRAM_ATTR placement".format(name)) - patched += 1 + patched.append(name) + else: + unpatched.append(name) if not patched: raise RuntimeError( "ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the " @@ -104,6 +132,20 @@ def _pre_link(target, source, env): build_dir ) ) + # Every .ld in the build must be patched. RTL8710B generates one .ld per + # OTA slot (xip1, xip2); if only one matches, the unpatched slot would + # ship with IRAM_ATTR code dropped to zeros and brick the device on the + # boot after an OTA into that slot. + if unpatched: + raise RuntimeError( + "ESPHome: {} of {} .ld file(s) in {} were not patched for " + "IRAM_ATTR: {}. The regex in patch_linker.py.script " + "(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not " + "these. Update the regex to cover all linker scripts.".format( + len(unpatched), len(ld_files), build_dir, + ", ".join(unpatched), _variant, + ) + ) # Substrings matched against demangled names as a fallback on RTL8720C, diff --git a/esphome/components/libretiny/preference_backend.h b/esphome/components/libretiny/preference_backend.h index 66b6847bee..f7f8279ac0 100644 --- a/esphome/components/libretiny/preference_backend.h +++ b/esphome/components/libretiny/preference_backend.h @@ -5,8 +5,10 @@ #include // Forward declare FlashDB types to avoid pulling in flashdb.h +// NOLINTBEGIN(readability-identifier-naming) struct fdb_kvdb; struct fdb_blob; +// NOLINTEND(readability-identifier-naming) namespace esphome::libretiny { diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 313b36d31e..d0bd3bf26b 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -70,12 +70,21 @@ void LibreTinyPreferences::open() { } ESPPreferenceObject LibreTinyPreferences::make_preference(size_t length, uint32_t type) { - auto *pref = new LibreTinyPreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->db = &this->db; - pref->blob = &this->blob; - pref->key = type; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new LibreTinyPreferenceBackend(this->make_backend_(type))); +} - return ESPPreferenceObject(pref); +LibreTinyPreferenceBackend LibreTinyPreferences::make_backend_(uint32_t type) { + LibreTinyPreferenceBackend backend; + backend.key = type; + backend.db = &this->db; + backend.blob = &this->blob; + return backend; +} + +bool LibreTinyPreferences::load_from_key(uint32_t type, uint8_t *data, size_t len) { + LibreTinyPreferenceBackend backend = this->make_backend_(type); + return backend.load(data, len); } bool LibreTinyPreferences::sync() { diff --git a/esphome/components/libretiny/preferences.h b/esphome/components/libretiny/preferences.h index 8365d590c2..fd86c48b20 100644 --- a/esphome/components/libretiny/preferences.h +++ b/esphome/components/libretiny/preferences.h @@ -16,6 +16,8 @@ class LibreTinyPreferences final : public PreferencesMixin return this->make_preference(length, type); } ESPPreferenceObject make_preference(size_t length, uint32_t type); + /// One-shot read of a stored preference by key, without allocating a backend + bool load_from_key(uint32_t type, uint8_t *data, size_t len); bool sync(); bool reset(); @@ -23,6 +25,7 @@ class LibreTinyPreferences final : public PreferencesMixin struct fdb_blob blob; protected: + LibreTinyPreferenceBackend make_backend_(uint32_t type); bool is_changed_(fdb_kvdb_t db, const NVSData &to_save, const char *key_str); }; 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/libretiny_pwm/output.py b/esphome/components/libretiny_pwm/output.py index 6f71530aaf..716ccfad2b 100644 --- a/esphome/components/libretiny_pwm/output.py +++ b/esphome/components/libretiny_pwm/output.py @@ -3,6 +3,9 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_PIN +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["libretiny"] @@ -21,7 +24,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: gpio = await cg.gpio_pin_expression(config[CONF_PIN]) var = cg.new_Pvariable(config[CONF_ID], gpio) await cg.register_component(var, config) @@ -40,7 +43,12 @@ async def to_code(config): ), synchronous=True, ) -async def libretiny_pwm_set_frequency_to_code(config, action_id, template_arg, args): +async def libretiny_pwm_set_frequency_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 9540c64486..dbcc28d64a 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -1,9 +1,13 @@ +from collections.abc import Callable from dataclasses import dataclass, field import enum +import logging import esphome.automation as auto import esphome.codegen as cg from esphome.components import mqtt, power_supply, web_server +from esphome.components.const import CONF_CHANNEL_COLORS, CONF_IS_WRGB +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BLUE, @@ -23,6 +27,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_INITIAL_STATE, + CONF_IS_RGBW, CONF_MQTT_ID, CONF_NAME, CONF_ON_STATE, @@ -32,6 +37,7 @@ from esphome.const import ( CONF_POWER_SUPPLY, CONF_RED, CONF_RESTORE_MODE, + CONF_RGB_ORDER, CONF_STATE, CONF_TRIGGER_ID, CONF_WARM_WHITE, @@ -58,9 +64,10 @@ from .effects import ( RGB_EFFECTS, validate_effects, ) -from .types import ( # noqa +from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +78,8 @@ from .types import ( # noqa light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -86,10 +95,22 @@ class EffectRef: component_path: list[str | int] # path_context when the action was validated +@dataclass +class EffectCycleRef: + """A pending light.effect.next/previous action to validate. + + Records that the referenced light needs at least one effect configured. + """ + + light_id: ID + component_path: list[str | int] + + @dataclass class LightData: gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr effect_refs: list[EffectRef] = field(default_factory=list) + effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list) def _get_data() -> LightData: @@ -153,20 +174,120 @@ def available_effects_str(effects: list) -> str: return ", ".join(f"'{name}'" for name in available) if available else "none" -def _final_validate(config: ConfigType) -> ConfigType: +# Accepted values of the deprecated `rgb_order` key. +RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG") + +_RGB_CHANNELS = frozenset("RGB") +_RGBW_CHANNELS = frozenset("RGBW") + + +def validate_channel_colors(value: str) -> str: + """Validate the channel order of an addressable strip, e.g. "GRB" or "WRGB".""" + value = cv.string_strict(value).upper() + channels = frozenset(value) + if len(channels) != len(value) or channels not in (_RGB_CHANNELS, _RGBW_CHANNELS): + raise cv.Invalid( + f"'{value}' is not a valid channel order. List each of R, G and B exactly " + "once, optionally with a single W, in the order the strip expects them " + "(for example GRB, GRBW or WRGB)" + ) + return value + + +def channel_colors_struct(value: str) -> cg.StructInitializer: + """Build the C++ `light::ChannelColors` for a validated channel order string.""" + return cg.StructInitializer( + ChannelColors, + ("r", value.index("R")), + ("g", value.index("G")), + ("b", value.index("B")), + ( + "w", + value.index("W") + if "W" in value + else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), + ), + ) + + +def _quote_and_join(keys: list[str]) -> str: + """Quote each key and join them into a readable list, e.g. "'a', 'b' and 'c'".""" + quoted = [f"'{key}'" for key in keys] + if len(quoted) == 1: + return quoted[0] + return f"{', '.join(quoted[:-1])} and {quoted[-1]}" + + +def migrate_channel_colors( + *, removed_in: str, component: str +) -> Callable[[ConfigType], ConfigType]: + """Fold the deprecated `rgb_order`, `is_rgbw` and `is_wrgb` keys into `channel_colors`. + + This also enforces that `channel_colors` is set, which the schema cannot do on its + own while the deprecated keys are still accepted. After this runs, `to_code` only + ever sees `channel_colors`. + """ + + def validator(config: ConfigType) -> ConfigType: + config = config.copy() + deprecated = [ + key for key in (CONF_RGB_ORDER, CONF_IS_RGBW, CONF_IS_WRGB) if key in config + ] + if CONF_CHANNEL_COLORS in config: + if deprecated: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' cannot be combined with " + f"{_quote_and_join(deprecated)}" + ) + return config + if CONF_RGB_ORDER not in config: + raise cv.Invalid( + f"'{CONF_CHANNEL_COLORS}' is required", path=[CONF_CHANNEL_COLORS] + ) + rgb_order = config.pop(CONF_RGB_ORDER) + is_rgbw = config.pop(CONF_IS_RGBW, False) + is_wrgb = config.pop(CONF_IS_WRGB, False) + if is_rgbw and is_wrgb: + raise cv.Invalid( + f"'{CONF_IS_RGBW}' and '{CONF_IS_WRGB}' cannot both be enabled" + ) + if is_wrgb: + channel_colors = f"W{rgb_order}" + elif is_rgbw: + channel_colors = f"{rgb_order}W" + else: + channel_colors = rgb_order + _LOGGER.warning( + "[%s] %s %s deprecated, use '%s: %s'. Will be removed in %s", + component, + _quote_and_join(deprecated), + "are" if len(deprecated) > 1 else "is", + CONF_CHANNEL_COLORS, + channel_colors, + removed_in, + ) + config[CONF_CHANNEL_COLORS] = channel_colors + return config + + return validator + + +def _final_validate(config: ConfigType) -> None: """Validate all recorded effect name references against their target lights. This runs once per light platform instance. If no light platform is configured, this never runs — but the ID validator will catch the missing light ID separately. """ data = _get_data() - if not data.effect_refs: - return config + if not data.effect_refs and not data.effect_cycle_refs: + return - # Drain the list so we only validate once even though + # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. refs = data.effect_refs data.effect_refs = [] + cycle_refs = data.effect_cycle_refs + data.effect_cycle_refs = [] fconf = fv.full_config.get() @@ -188,7 +309,20 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) - return config + for ref in cycle_refs: + try: + light_path = fconf.get_path_for_id(ref.light_id)[:-1] + light_config = fconf.get_config_for_path(light_path) + except KeyError: + continue + + if not light_config.get(CONF_EFFECTS): + raise cv.FinalExternalInvalid( + f"Light '{ref.light_id}' has no effects configured, but a " + f"'light.effect.next' or 'light.effect.previous' action " + f"references it. Add at least one effect to the light.", + path=[cv.ROOT_CONFIG_PATH] + ref.component_path, + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -424,3 +558,10 @@ async def new_light(config, *args): @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(light_ns.using) + + +# light_json_schema.cpp is only used by mqtt and web_server, which both +# auto load json; USE_JSON alone is too broad since other components load it. +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"light_json_schema.cpp": ("USE_MQTT", "USE_WEBSERVER")} +) 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 993d4a2ea6..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) {} @@ -104,7 +104,48 @@ template class DimRelativeAction : pub transition_length_{}; }; -template class LightIsOnCondition : public Condition { +// Cycle through the light's configured effects. `Forward` selects direction +// 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 final : public Action { + public: + explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {} + + void set_include_none(bool include_none) { this->include_none_ = include_none; } + + void play(const Ts &...) override { + size_t count = this->parent_->get_effect_count(); + if (count == 0) { + return; + } + uint32_t current = this->parent_->get_current_effect_index(); + uint32_t next; + if (this->include_none_) { + uint32_t total = static_cast(count) + 1; + if constexpr (Forward) { + next = (current + 1) % total; + } else { + next = (current + total - 1) % total; + } + } else { + if constexpr (Forward) { + next = (current % static_cast(count)) + 1; + } else { + next = (current <= 1) ? static_cast(count) : current - 1; + } + } + auto call = this->parent_->turn_on(); + call.set_effect(next); + call.perform(); + } + + protected: + LightState *parent_; + bool include_none_{false}; +}; + +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(); } @@ -112,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(); } @@ -121,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); @@ -146,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); @@ -164,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); } @@ -175,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/automation.py b/esphome/components/light/automation.py index cef774af38..7eaba9b117 100644 --- a/esphome/components/light/automation.py +++ b/esphome/components/light/automation.py @@ -26,8 +26,8 @@ from esphome.const import ( CONF_WARM_WHITE, CONF_WHITE, ) -from esphome.core import CORE, EsphomeError, Lambda -from esphome.cpp_generator import LambdaExpression +from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType from esphome.types import ConfigType from .types import ( @@ -39,12 +39,15 @@ from .types import ( DimRelativeAction, LightCall, LightControlAction, + LightEffectCycleAction, LightIsOffCondition, LightIsOnCondition, LightState, ToggleAction, ) +CONF_INCLUDE_NONE = "include_none" + @automation.register_action( "light.toggle", @@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda) +def _record_effect_cycle_ref(config: ConfigType) -> ConfigType: + """Record a cycle-action reference for later validation against the target light.""" + from . import EffectCycleRef, _get_data + + _get_data().effect_cycle_refs.append( + EffectCycleRef( + light_id=config[CONF_ID], + component_path=path_context.get(), + ) + ) + return config + + +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(LightState), + cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean, + } +) +LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref) + +LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id( + LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA +) + + +@automation.register_action( + "light.effect.next", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_next_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, True) + + +@automation.register_action( + "light.effect.previous", + LightEffectCycleAction, + LIGHT_EFFECT_CYCLE_ACTION_SCHEMA, + synchronous=True, +) +async def light_effect_previous_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return await _light_effect_cycle_to_code(config, action_id, template_arg, False) + + +async def _light_effect_cycle_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + forward: bool, +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + cycle_template_arg = cg.TemplateArguments(forward, *template_arg) + var = cg.new_Pvariable(action_id, cycle_template_arg, paren) + cg.add(var.set_include_none(config[CONF_INCLUDE_NONE])) + return var + + CONF_RELATIVE_BRIGHTNESS = "relative_brightness" LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema( { diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index cdb9f1f666..ba3fba6c12 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -111,7 +111,7 @@ class RandomLightEffect : public LightEffect { class LambdaLightEffect : public LightEffect { public: - LambdaLightEffect(const char *name, void (*f)(bool initial_run), uint32_t update_interval) + LambdaLightEffect(const char *name, void (*f)(LightState &, bool initial_run), uint32_t update_interval) : LightEffect(name), f_(f), update_interval_(update_interval) {} void start() override { this->initial_run_ = true; } @@ -119,7 +119,7 @@ class LambdaLightEffect : public LightEffect { const uint32_t now = millis(); if (now - this->last_run_ >= this->update_interval_ || this->initial_run_) { this->last_run_ = now; - this->f_(this->initial_run_); + this->f_(*this->state_, this->initial_run_); this->initial_run_ = false; } } @@ -129,7 +129,7 @@ class LambdaLightEffect : public LightEffect { uint32_t get_current_index() const { return this->get_index(); } protected: - void (*f_)(bool initial_run); + void (*f_)(LightState &, bool initial_run); uint32_t update_interval_; uint32_t last_run_{0}; bool initial_run_; diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 0000000000..9d8f46d575 --- /dev/null +++ b/esphome/components/light/channel_colors.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace esphome::light { + +/// Which byte of an addressable LED's data carries each colour. +/// +/// Built from a configuration string such as "GRB" or "WRGB": every field holds the +/// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when +/// the strip has no separate white channel. +struct ChannelColors { + /// Value of `w` for a strip that only has red, green and blue channels. + static constexpr uint8_t NO_WHITE = 0xFF; + + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + + bool has_white() const { return this->w != NO_WHITE; } + + uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + + /// Write the order back out as text, e.g. "GRBW". + /// + /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be + /// passed straight to a log call. + const char *to_string(char *buf) const { + buf[this->r] = 'R'; + buf[this->g] = 'G'; + buf[this->b] = 'B'; + if (this->has_white()) { + buf[this->w] = 'W'; + } + buf[this->bytes_per_led()] = '\0'; + return buf; + } +}; + +} // namespace esphome::light diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 4088a78e0d..3ae15f9ee5 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -51,6 +51,7 @@ from .types import ( FlickerLightEffect, LambdaLightEffect, LightColorValues, + LightStateRef, PulseLightEffect, RandomLightEffect, StrobeLightEffect, @@ -175,7 +176,9 @@ def register_addressable_effect( ) async def lambda_effect_to_code(config, effect_id): lambda_ = await cg.process_lambda( - config[CONF_LAMBDA], [(bool, "initial_run")], return_type=cg.void + config[CONF_LAMBDA], + [(LightStateRef, "it"), (bool, "initial_run")], + return_type=cg.void, ) return cg.new_Pvariable( effect_id, config[CONF_NAME], lambda_, config[CONF_UPDATE_INTERVAL] diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 58d552031a..5d372983d9 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -13,8 +13,6 @@ ESPColorView ESPRangeView::operator[](int32_t index) const { index = interpret_index(index, this->size()) + this->begin_; return (*this->parent_)[index]; } -ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } -ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } void ESPRangeView::set(const Color &color) { for (int32_t i = this->begin_; i < this->end_; i++) { diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index f5e4ebb83f..ec129bdf70 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -75,4 +75,7 @@ class ESPRangeIterator { int32_t i_; }; +inline ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } +inline ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } + } // namespace esphome::light diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7b28065e4e..4251565e85 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -213,18 +213,29 @@ 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. - 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); - } + } + + // A light without brightness control has no way to represent "on but dark", so zero + // brightness -- how effects encode their dark phase -- means the light is off. Clear the + // brightness as well, so a zero can't linger in remote_values and leave the light stuck + // off: a later turn-on can't heal it, because the capability check below drops any + // brightness this mode doesn't support. explicit_turn_off_request was captured above, so + // a running effect is not stopped by this. + if (this->has_brightness() && this->brightness_ == 0.0f && !(color_mode & ColorCapability::BRIGHTNESS)) { + this->state_ = false; + this->set_flag_(FLAG_HAS_STATE); + this->clear_flag_(FLAG_HAS_BRIGHTNESS); + } + + // Make sure a simple (no specific brightness) turn-on makes the light visible + if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() && + this->parent_->remote_values.get_brightness() == 0.0f) { + this->brightness_ = 1.0f; + this->set_flag_(FLAG_HAS_BRIGHTNESS); } // Set color brightness to 100% if currently zero and a color is set. 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/light_state.cpp b/esphome/components/light/light_state.cpp index bd778926d5..82c00e2382 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -71,6 +71,14 @@ void LightState::setup() { break; } + // A light coming up on boot must never end up on-but-invisible: if the resolved restore + // state is on but its brightness is zero (e.g. a stale/persisted value from before a + // forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on), + // reset it to full brightness. + if (recovered.state && recovered.brightness == 0.0f) { + recovered.brightness = 1.0f; + } + call.set_color_mode_if_supported(recovered.color_mode); call.set_state(recovered.state); call.set_brightness_if_supported(recovered.brightness); @@ -149,8 +157,6 @@ void LightState::loop() { } } -float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } - void LightState::publish_state() { if (this->remote_values_listeners_) { for (auto *listener : *this->remote_values_listeners_) { @@ -186,25 +192,11 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::set_default_transition_length(uint32_t default_transition_length) { - this->default_transition_length_ = default_transition_length; -} -uint32_t LightState::get_default_transition_length() const { return this->default_transition_length_; } -void LightState::set_flash_transition_length(uint32_t flash_transition_length) { - this->flash_transition_length_ = flash_transition_length; -} -uint32_t LightState::get_flash_transition_length() const { return this->flash_transition_length_; } -void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } -void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } -void LightState::set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } -bool LightState::supports_effects() { return !this->effects_.empty(); } -const FixedVector &LightState::get_effects() const { return this->effects_; } void LightState::add_effects(const std::initializer_list &effects) { // Called once from Python codegen during setup with all effects from YAML config this->effects_ = effects; } -void LightState::current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -325,8 +317,6 @@ float LightState::gamma_uncorrect_lut(float value) const { } #endif // USE_LIGHT_GAMMA_LUT -bool LightState::is_transformer_active() { return this->is_transformer_active_; } - void LightState::start_effect_(uint32_t effect_index) { this->stop_effect_(); if (effect_index == 0) diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 5efc05358b..3a3f8fc368 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -109,7 +109,7 @@ class LightState : public EntityBase, public Component { void dump_config() override; void loop() override; /// Shortly after HARDWARE. - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::HARDWARE - 1.0f; } /** The current values of the light as outputted to the light. * @@ -157,15 +157,19 @@ class LightState : public EntityBase, public Component { void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. - void set_default_transition_length(uint32_t default_transition_length); - uint32_t get_default_transition_length() const; + void set_default_transition_length(uint32_t default_transition_length) { + this->default_transition_length_ = default_transition_length; + } + uint32_t get_default_transition_length() const { return this->default_transition_length_; } /// Set the flash transition length - void set_flash_transition_length(uint32_t flash_transition_length); - uint32_t get_flash_transition_length() const; + void set_flash_transition_length(uint32_t flash_transition_length) { + this->flash_transition_length_ = flash_transition_length; + } + uint32_t get_flash_transition_length() const { return this->flash_transition_length_; } /// Set the gamma correction factor - void set_gamma_correct(float gamma_correct); + void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; } float get_gamma_correct() const { return this->gamma_correct_; } #ifdef USE_LIGHT_GAMMA_LUT @@ -186,17 +190,17 @@ class LightState : public EntityBase, public Component { #endif // USE_LIGHT_GAMMA_LUT /// Set the restore mode of this light - void set_restore_mode(LightRestoreMode restore_mode); + void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } /// Set a callback to populate the initial state defaults during setup. /// The callback is called once, then cleared. Values live in flash as code. - void set_initial_state(void (*callback)(LightStateRTCState &)); + void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; } /// Return whether the light has any effects that meet the trait requirements. - bool supports_effects(); + bool supports_effects() const { return !this->effects_.empty(); } /// Get all effects for this light state. - const FixedVector &get_effects() const; + const FixedVector &get_effects() const { return this->effects_; } /// Add effects for this light state. void add_effects(const std::initializer_list &effects); @@ -254,7 +258,7 @@ class LightState : public EntityBase, public Component { } /// The result of all the current_values_as_* methods have gamma correction applied. - void current_values_as_binary(bool *binary); + void current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); } void current_values_as_brightness(float *brightness); @@ -281,7 +285,7 @@ class LightState : public EntityBase, public Component { * return; * } */ - bool is_transformer_active(); + bool is_transformer_active() const { return this->is_transformer_active_; } protected: friend LightOutput; 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/light/types.py b/esphome/components/light/types.py index 534dcd2194..1778aa8410 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -4,6 +4,7 @@ import esphome.codegen as cg # Base light_ns = cg.esphome_ns.namespace("light") LightState = light_ns.class_("LightState", cg.EntityBase, cg.Component) +LightStateRef = LightState.operator("ref") AddressableLightState = light_ns.class_("AddressableLightState", LightState) LightOutput = light_ns.class_("LightOutput") AddressableLight = light_ns.class_("AddressableLight", LightOutput, cg.Component) @@ -15,6 +16,9 @@ LightColorValues = light_ns.class_("LightColorValues") LightStateRTCState = light_ns.struct("LightStateRTCState") LightCall = light_ns.class_("LightCall") +# Addressable strips +ChannelColors = light_ns.struct("ChannelColors") + # Color modes ColorMode = light_ns.enum("ColorMode", is_class=True) COLOR_MODES = { @@ -39,6 +43,7 @@ LIMIT_MODES = { # Actions ToggleAction = light_ns.class_("ToggleAction", automation.Action) LightControlAction = light_ns.class_("LightControlAction", automation.Action) +LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action) DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action) AddressableSet = light_ns.class_("AddressableSet", automation.Action) LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition) diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 76eabc2b71..0f42083cb5 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,7 +11,10 @@ from esphome.const import ( CONF_REPEAT, CONF_WRITE_PIN, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.cpp_helpers import gpio_pin_expression +from esphome.types import ConfigType CODEOWNERS = ["@max246"] @@ -57,7 +60,12 @@ LIGHTWAVE_SEND_SCHEMA = cv.Any( LIGHTWAVE_SEND_SCHEMA, synchronous=True, ) -async def send_raw_to_code(config, action_id, template_arg, args): +async def send_raw_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) @@ -71,7 +79,7 @@ async def send_raw_to_code(config, action_id, template_arg, args): return var -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/__init__.py b/esphome/components/lilygo_t5_47/touchscreen/__init__.py index 93687846e2..1e70f379a1 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/__init__.py +++ b/esphome/components/lilygo_t5_47/touchscreen/__init__.py @@ -3,6 +3,7 @@ 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 +from esphome.types import ConfigType from .. import lilygo_t5_47_ns @@ -29,7 +30,7 @@ CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) 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/lm75b/sensor.py b/esphome/components/lm75b/sensor.py index 335446b62f..c59515b5b0 100644 --- a/esphome/components/lm75b/sensor.py +++ b/esphome/components/lm75b/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType CODEOWNERS = ["@beormund"] DEPENDENCIES = ["i2c"] @@ -28,7 +29,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py new file mode 100644 index 0000000000..aadc1f3b2f --- /dev/null +++ b/esphome/components/ln882h_ble/__init__.py @@ -0,0 +1,53 @@ +"""LN882H BLE — BLE controller support for the LN882H LibreTiny chips. + +The platform analog of esp32_ble / rp2040_ble: owns the LN882H BLE stack +bring-up and the controller BLE address. Consumers (ln882h_ble_tracker) build +on this component and contain no SDK calls of their own. + +The BLE stack is compiled and linked by the LibreTiny lightning-ln882h builder +when CFG_SUPPORT_BLE=1 is set via custom_options.proj_config#h, which this +component does (LibreTiny v1.13.0+). +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["ln882x"] +CODEOWNERS = ["@Bl00d-B0b"] + +ln882h_ble_ns = cg.esphome_ns.namespace("ln882h_ble") +LN882HBLE = ln882h_ble_ns.class_("LN882HBLE", cg.Component) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LN882HBLE), + # Default off: on the single-core LN882H, bringing the BLE stack up during + # boot competes with the WiFi connection handshake. Consumers enable the + # stack lazily on first use (e.g. the tracker's first scan start). + cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +# Once per registered scan listener; sizes the controller's StaticVector +# listener storage. +request_scan_listener_slot = cg.slot_counter("LN882H_BLE_SCAN_LISTENER_COUNT") + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + + # Enable the BLE stack in the build. The LibreTiny lightning-ln882h builder + # gates the BLE stack sources and libraries on CFG_SUPPORT_BLE, read from + # the custom option key "proj_config#h" (the '#h' maps to proj_config.h) + # before the stock header's default of 0. A list is used so any other + # component adding to this key concatenates with it rather than replacing + # it (add_platformio_option only merges when both values are lists). + cg.add_platformio_option("custom_options.proj_config#h", ["CFG_SUPPORT_BLE=1"]) + + cg.add_define("USE_LN882H_BLE") diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp new file mode 100644 index 0000000000..021e138f08 --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -0,0 +1,473 @@ +// ln882h_ble.cpp +// +// BLE controller support for the LN882H (LibreTiny lightning-ln882h family) — +// the platform analog of esp32_ble / rp2040_ble. Owns everything that talks to +// the LN882H BLE SDK: +// - one-time stack bring-up (rw_init + the ln_* app init sequence), +// - the controller BLE address (persistent KV entry, WiFi-MAC-derived once), +// - the raw controller scan primitives (ln_ble_scan_start/stop), +// - the scan-report ring: the SDK's rw-task event callback decodes each +// report (including the controller's RSSI sign quirk) into a fixed pool +// and pushes it on a lock-free SPSC queue; loop() drains, dispatches on +// the main task and returns reports to the pool — the same EventPool + +// LockFreeQueue handoff esp32_ble uses, zero allocation at steady state. +// Consumers contain no SDK calls of their own. +// +// BLE stack init and scan lifecycle mirror the SDK's ble_app usage. The BLE +// stack itself is compiled and linked by the LibreTiny lightning-ln882h builder +// (CFG_SUPPORT_BLE=1 via custom_options.proj_config#h; prebuilt +// libln882h_ble_full_stack.a). + +#include "ln882h_ble.h" // pulls esphome/core/defines.h for USE_LN882H_BLE + +#ifdef USE_LN882H_BLE + +#include +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" // get_mac_address_raw() +#include "esphome/core/log.h" + +// --------------------------------------------------------------------------- +// LN882H BLE SDK — forward declarations +// --------------------------------------------------------------------------- +extern "C" { + +struct ln_bd_addr_v_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t addr[6]; +}; // ABI-identical to ln_bd_addr_t +void ln_kv_ble_app_init(void); +struct ln_bd_addr_v_t *ln_kv_ble_pub_addr_get(void); +int ln_kv_ble_addr_store(struct ln_bd_addr_v_t addr); +void soc_module_clk_gate_enable(uint32_t clk); + +void rw_init(uint8_t mac[6]); +void ln_gap_app_init(void); +void ln_gatt_app_init(void); +void ln_ble_conn_mgr_init(void); +void ln_ble_evt_mgr_init(void); +void ln_ble_smp_init(void); +void ln_ble_scan_mgr_init(void); +void ln_rw_app_task_init(void); +void ln_gap_reset(void); + +void ln_ble_scan_actv_creat(void); +void ln_ble_scan_start(void *scan_param); +void ln_ble_scan_stop(void); + +using ble_evt_cb_t = void (*)(void *param); +void ln_ble_evt_mgr_reg_evt(int evt_id, ble_evt_cb_t cb); + +} // extern "C" + +// ln_bd_addr_v_t mirrors the SDK's ln_bd_addr_t (ln_ble_app_defines.h) and is +// passed to ln_kv_ble_addr_store() by value, so its size and alignment are part +// of the calling convention. +static_assert(sizeof(struct ln_bd_addr_v_t) == 6, "ln_bd_addr_v_t must match the SDK's ln_bd_addr_t layout"); +static_assert(alignof(struct ln_bd_addr_v_t) == 1, "ln_bd_addr_v_t must stay byte-aligned like the SDK type"); + +// --------------------------------------------------------------------------- +// LN882H SDK constants +// CLK_G_BLE — hal/hal_clock.h clock gate bit for the BLE block +// BLE_EVT_ID_SCAN_REPORT — ble/ble_evt.h event id for scan reports +// GAPM_* — ble/mac/ble/hl/api/gapm_task.h, enums gapm_scan_type / +// gapm_dup_filter_pol / gapm_scan_prop / gapm_adv_report_info +// --------------------------------------------------------------------------- +static constexpr uint32_t CLK_G_BLE = 1u << 0; +static constexpr int BLE_EVT_ID_SCAN_REPORT = 3; + +// WiFi/BLE packet-traffic-indication (PTI) arbitration register. The LN882H SDK +// exposes no symbolic name for this register; the address and value replicate +// the SDK reference bring-up. 0x003F sets all six PTI priority bits so the +// arbiter can pre-empt WiFi for BLE traffic. +static constexpr uint32_t BLE_COEX_PTI_REG_ADDR = 0x400121F8; +static constexpr uint32_t BLE_COEX_PTI_ENABLE_ALL = 0x003F; + +// ble_app_default_cfg.h BLE_DEFAULT_PUBLIC_ADDR, in the SDK's ln_bd_addr_t +// array order — least-significant octet first, the BLE/HCI convention (the +// SDK's own AT commands print addr[5]..addr[0]). Printable form: +// 00:FF:03:12:34:56. +static constexpr uint8_t BLE_DEFAULT_ADDR[6] = {0x56, 0x34, 0x12, 0x03, 0xFF, 0x00}; + +// The SDK's KV loader treats an all-zero address as unset and substitutes the +// default; resolve_mac_() applies the same rule to a stored entry. +static bool is_unset_addr(const uint8_t (&addr)[6]) { + return std::all_of(std::begin(addr), std::end(addr), [](uint8_t b) { return b == 0; }); +} + +// gapm_scan_type: GEN_DISC = 0, LIM_DISC = 1, OBSERVER = 2. Observer reports every +// advertisement without filtering — what a tracker wants. +static constexpr uint8_t GAPM_SCAN_TYPE_OBSERVER = 2; +static constexpr uint8_t GAPM_DUP_FILT_DIS = 0; +// gapm_scan_prop bits: PHY_1M = 1<<0, PHY_CODED = 1<<1, ACTIVE_1M = 1<<2, ACTIVE_CODED = 1<<3. +static constexpr uint8_t GAPM_SCAN_PROP_PHY_1M_BIT = 1 << 0; +static constexpr uint8_t GAPM_SCAN_PROP_ACTIVE_1M_BIT = 1 << 2; + +// GAPM extended-advertising report types (bits 2:0 of ble_scan_report_t::info). +// 0 = ADV_EXT (extended advertisement), 1 = ADV_LEG (legacy advertisement), +// 2 = SCAN_RSP_EXT (scan response to extended adv), 3 = SCAN_RSP_LEG (scan response to legacy adv). +static constexpr uint8_t GAPM_REPORT_TYPE_ADV_LEG = 1; +static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3; +// Bit 5 of ble_scan_report_t::info: the advertisement is scannable, i.e. a scan +// response may follow (enum gapm_adv_report_info, GAPM_REPORT_INFO_SCAN_ADV_BIT). +static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1u << 5; + +// --------------------------------------------------------------------------- +// SDK struct layouts +// --------------------------------------------------------------------------- + +// Scan parameter block passed to ln_ble_scan_start(); mirrors the SDK layout, +// with the pad byte explicit so the whole block zero-initialises. +struct le_scan_parameters_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t type; + uint8_t prop; + uint8_t dup_filt_pol; + uint8_t pad; + uint16_t scan_intv; + uint16_t scan_wd; +}; +// Pin the compiler's layout decisions for the hand-mirrored SDK struct: it is +// passed to ln_ble_scan_start() as void*, so a padding drift would silently +// feed garbage scan parameters to the controller. +static_assert(sizeof(le_scan_parameters_t) == 8, "le_scan_parameters_t must match the SDK layout"); +static_assert(offsetof(le_scan_parameters_t, scan_intv) == 4, "unexpected padding in le_scan_parameters_t"); + +// Scan report delivered by the BLE_EVT_ID_SCAN_REPORT event. Layout verified on +// hardware against the prebuilt BLE stack LibreTiny links: its report carries no +// PHY fields and stores the advertisement data inline (flexible array), unlike +// the newer upstream SDK header (which adds phy_prim/phy_second and a data pointer). +struct ble_scan_report_t { // NOLINT(readability-identifier-naming) - mirrors the SDK type name + uint8_t actv_idx; + uint8_t info; + uint8_t trans_addr_type; + uint8_t trans_addr[6]; + uint8_t target_addr_type; + uint8_t target_addr[6]; + int8_t tx_pwr; + int8_t rssi; // signed dBm, range -127..+20 (ble_evt_scan_report_t from ln_ble_event_manager.h) + uint16_t length; + uint8_t data[0]; +}; +// Pin the layout of the hand-mirrored report struct too: the comment above +// notes a newer SDK header uses a different layout (PHY fields + data pointer), +// so silent drift here would corrupt every decoded advertisement. +static_assert(sizeof(ble_scan_report_t) == 20, "ble_scan_report_t must match the linked BLE stack's layout"); +static_assert(offsetof(ble_scan_report_t, length) == 18, "unexpected padding in ble_scan_report_t"); +static_assert(offsetof(ble_scan_report_t, data) == 20, "advertisement data must follow the header inline"); + +// --------------------------------------------------------------------------- +// __sprintf weak stub +// +// The LN882H BLE SDK objects reference __sprintf (a Beken/LN libc alias) that +// LibreTiny's newlib does not provide. Supply a weak fallback so linking +// succeeds; a real definition, if one is ever provided, takes precedence. +// --------------------------------------------------------------------------- +#include +#include +extern "C" __attribute__((weak)) int +__sprintf( // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + char *str, const char *format, ...) { + va_list args; + va_start(args, format); + int ret = vsprintf(str, format, args); // NOLINT + va_end(args); + return ret; +} + +namespace esphome::ln882h_ble { + +static const char *const TAG = "ln882h_ble"; + +// The SDK event callback is a plain C function pointer with no user argument, +// so it reaches the (single) component instance through a file-static pointer. +static LN882HBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Scan parameter blocks handed to ln_ble_scan_start(void *). static storage: +// the SDK may retain the pointer past the call (the block travels into a GAPM +// message consumed later by the rw task), so a stack-local would leave the +// controller reading a dead frame. Double-buffered: consecutive starts (the +// enable() probe followed by the first real scan, or a parameter restart) +// alternate blocks, so a rewrite can never race a previous block that is still +// in flight — correct under either reading of SDK retention. All writers run +// on the main task. +static le_scan_parameters_t s_scan_params[2]{}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static uint8_t s_scan_params_idx = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static le_scan_parameters_t *next_scan_params() { + s_scan_params_idx ^= 1; + return &s_scan_params[s_scan_params_idx]; +} + +// --------------------------------------------------------------------------- +// Scan-report event callback — runs in the SDK's rw task context. +// Decode the report (hardware-verified struct layout + the RSSI sign fix), +// copy it into the queue and return; all dispatch happens in loop() on the +// main task. +// --------------------------------------------------------------------------- +static void ble_scan_callback(void *param) { + if (s_ble == nullptr || param == nullptr) + return; + const auto *info = reinterpret_cast(param); + + // Only legacy framing is supported (see scan_start(): legacy 1M PHY only): + // an extended report does not fit BLEScanReport::data and would reach + // consumers as a truncated legacy frame. Reject before allocating so these + // do not burn pool slots either. + const uint8_t report_type = info->info & 0x07; + if (report_type != GAPM_REPORT_TYPE_ADV_LEG && report_type != GAPM_REPORT_TYPE_SCAN_RSP_LEG) { + s_ble->count_rejected_report(); + return; + } + + // Fill the pool slot in place (the bk72xx_ble shape): no report on the rw + // task's stack — its size is fixed by the prebuilt stack — one copy of the + // payload instead of two, and only data_len bytes ever leave this frame. + BLEScanReport *slot = s_ble->allocate_scan_report(); + if (slot == nullptr) + return; // no slot — counted as dropped in allocate_scan_report() + + // BLE RSSI sign fix. The LN882H controller intermittently reports the RSSI with + // a flipped sign: a real -58 dBm arrives as +58, above the SDK's documented + // -127..+20 dBm maximum. Recover it by negating any value above +20 (verified + // on-device: the out-of-range positives cluster at the magnitude of each + // device's real readings). This is the ONLY LN882H-specific RSSI handling — + // downstream the value is used exactly like on ESP32. + const int8_t raw = info->rssi; + + memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE); + slot->rssi = (raw > 20) ? static_cast(-raw) : raw; + slot->addr_type = info->trans_addr_type; + slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; + slot->scannable = (info->info & GAPM_REPORT_INFO_SCAN_ADV_BIT) != 0; + slot->data_len = (info->length <= sizeof(slot->data)) ? static_cast(info->length) + : static_cast(sizeof(slot->data)); + memcpy(slot->data, info->data, slot->data_len); + + s_ble->push_scan_report(slot); +} + +BLEScanReport *LN882HBLE::allocate_scan_report() { + BLEScanReport *slot = this->report_pool_.allocate(); + if (slot == nullptr) { + // No slot: pool exhausted (queue full) or the pool's on-demand RAM + // allocation failed; count and drop either way. + this->report_queue_.increment_dropped_count(); + } + return slot; +} + +void LN882HBLE::push_scan_report(BLEScanReport *report) { + // Cannot fail: the pool is sized to the queue capacity. + this->report_queue_.push(report); +} + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void LN882HBLE::setup() { + s_ble = this; + // Resolve the MAC early so get_mac_lsb_first() is valid for consumers before + // the stack is up. The KV load also happens here (no stack dependency). + this->resolve_mac_(); + if (this->enable_on_boot_) { + this->enable(); + } +} + +// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the LN +// SDK's KV subsystem is first touched only once WiFi is up; earlier access +// destabilized the device in hardware testing. +float LN882HBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; } + +void LN882HBLE::enable() { + if (this->state_ != BLEComponentState::STATE_OFF) + return; + this->state_ = BLEComponentState::ENABLING; + + *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; + soc_module_clk_gate_enable(CLK_G_BLE); + + rw_init(this->ble_mac_); + ln_gap_app_init(); + ln_gatt_app_init(); + ln_ble_conn_mgr_init(); + ln_ble_evt_mgr_init(); + ln_ble_smp_init(); + ln_ble_scan_mgr_init(); + ln_rw_app_task_init(); + ln_gap_reset(); + + delay(100); // NOLINT — one-time BLE stack init; SDK requires this settle time + + ln_ble_scan_actv_creat(); + delay(10); + + // Prime the scan activity with a short probe start/stop — the SDK's scan + // manager completes activity creation on the first start. Uses the shared + // static parameter block (see s_scan_params for the lifetime rationale). + le_scan_parameters_t *probe = next_scan_params(); + probe->type = GAPM_SCAN_TYPE_OBSERVER; + probe->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + probe->dup_filt_pol = GAPM_DUP_FILT_DIS; + probe->scan_intv = 160; + probe->scan_wd = 16; + ln_ble_scan_start(probe); + delay(10); + ln_ble_scan_stop(); + + // Register the scan-report event exactly once, after the event manager is up. + // Repeated registration corrupts the SDK's event registry (verified on + // hardware), which is why this lives here and not in scan_start(). + ln_ble_evt_mgr_reg_evt(BLE_EVT_ID_SCAN_REPORT, ble_scan_callback); + + this->state_ = BLEComponentState::ACTIVE; + ESP_LOGD(TAG, "BLE stack initialised"); +} + +void LN882HBLE::loop() { + // Log dropped reports before the empty-queue return: a drop can also mean + // EventPool::allocate() failed on heap exhaustion, and that can happen with + // the queue empty — from the very first report on. Checking here keeps that + // failure visible instead of producing a scanner that is silently dead. + uint16_t dropped = this->report_queue_.get_and_reset_dropped_count(); + if (dropped > 0) + ESP_LOGW(TAG, "Dropped %u scan reports (queue full or out of memory for a report slot)", dropped); + // Drain the lock-free ring filled by the rw task; all per-report work runs + // here on the main task, then the report returns to the pool. + BLEScanReport *report = this->report_queue_.pop(); + if (report != nullptr) { + this->reject_diagnosis_done_ = true; + do { +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + for (auto *listener : this->scan_listeners_) + listener->on_scan_report(*report); +#endif + this->report_pool_.release(report); + } while ((report = this->report_queue_.pop()) != nullptr); + } + + // Rejected-report accounting AFTER the drain: a stray non-legacy frame + // arriving ahead of the first good one must not latch the dead-scanner + // warning; the threshold keeps one-off boot noise below it while a truly + // dead scanner (~200 reports/s all rejected) crosses it within a second. + // Avoid the sub-word CAS in the common case (LockFreeQueue's dropped-count + // pattern): rejects are rare, the load is cheap. + uint16_t rejected = this->rejected_reports_.load(std::memory_order_relaxed); + if (rejected > 0) { + rejected = this->rejected_reports_.exchange(0, std::memory_order_relaxed); + if (!this->reject_diagnosis_done_) { + this->rejected_before_delivery_ += rejected; + if (this->rejected_before_delivery_ >= REJECTED_DEAD_SCANNER_THRESHOLD) { + this->reject_diagnosis_done_ = true; + ESP_LOGW(TAG, "Rejected %u scan reports before any was delivered - unexpected report encoding?", + static_cast(this->rejected_before_delivery_)); + } + } + ESP_LOGV(TAG, "Rejected %u non-legacy scan reports", rejected); + } +} + +void LN882HBLE::get_mac_lsb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, sizeof(this->ble_mac_)); } + +void LN882HBLE::dump_config() { + ESP_LOGCONFIG(TAG, + "LN882H BLE:\n" + " MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n" + " Active: %s", + this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1], + this->ble_mac_[0], YESNO(this->is_active())); +} + +// --------------------------------------------------------------------------- +// MAC resolution +// --------------------------------------------------------------------------- + +void LN882HBLE::resolve_mac_() { + ln_kv_ble_app_init(); + + // ln_kv_ble_app_init() loads the persistent address from the "2_ble_addr" KV + // entry, falling back to BLE_DEFAULT_ADDR when nothing is stored. A stored + // address is preferred so boot does not write flash. Order is LSB-first + // throughout (BLE/HCI convention); consumers reverse for printable form. + ln_bd_addr_v_t bt_addr{}; + bool have_unique_addr = false; + if (const ln_bd_addr_v_t *stored = ln_kv_ble_pub_addr_get(); stored != nullptr) { + bt_addr = *stored; + // All-zero is "unset", not a unique address: ln_kv_ble_addr_load() itself + // substitutes the default for it, so programming it verbatim would give the + // controller a null address. Treat it like the default and derive instead. + have_unique_addr = + memcmp(bt_addr.addr, BLE_DEFAULT_ADDR, sizeof(bt_addr.addr)) != 0 && !is_unset_addr(bt_addr.addr); + } else { + // KV subsystem down (wrong partition layout, corrupted region): derive + // below instead of dereferencing null and boot-looping. + ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); + } + if (!have_unique_addr) { + uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0}; + get_mac_address_raw(wifi_mac); // MSB-first + // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment + // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the + // Beken/Tuya factory pairing the bk72xx sibling also uses. + for (int i = 0; i < 6; i++) + bt_addr.addr[i] = wifi_mac[5 - i]; + bt_addr.addr[0] = static_cast(bt_addr.addr[0] + 1); + if (int err = ln_kv_ble_addr_store(bt_addr); err != 0) { + ESP_LOGW(TAG, "Failed to persist derived BLE address (err %d); will re-derive next boot", err); + } else { + ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); + } + } + memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE); +} + +// --------------------------------------------------------------------------- +// Controller scan primitives +// --------------------------------------------------------------------------- + +void LN882HBLE::scan_start(uint16_t interval, uint16_t window, bool active) { + if (!this->is_active()) + this->enable(); + + if (this->scanning_) { + // Already scanning - stop first so this call cleanly restarts with the new + // parameters (re-entry guard). Give the GAPM stop the same settle time + // enable() grants between consecutive GAPM operations before restarting. + this->scan_stop(); + delay(10); // NOLINT — restart-only, mirrors enable()'s inter-operation settle + } + + // Double-buffered static block — see s_scan_params for the lifetime rationale. + le_scan_parameters_t *p = next_scan_params(); + p->dup_filt_pol = GAPM_DUP_FILT_DIS; + p->type = GAPM_SCAN_TYPE_OBSERVER; + p->scan_intv = interval; + p->scan_wd = window; + // Legacy 1M PHY only: consumers size their buffers for legacy advertisements + // (62 B); coded/extended PHY (up to 255 B) would be silently truncated. + p->prop = GAPM_SCAN_PROP_PHY_1M_BIT; + if (active) + p->prop |= GAPM_SCAN_PROP_ACTIVE_1M_BIT; + + ln_ble_scan_start(p); + // ln_ble_scan_start() returns void, so this tracks the requested state, not a + // confirmed one — a controller-side failure surfaces as an idle scanner (no + // reports), which the consumer's start retry/backoff owns. + this->scanning_ = true; +} + +void LN882HBLE::scan_stop() { + // No-op when idle, as documented: the guard keeps a redundant SDK stop off + // the GAPM path (scan_start()'s re-entry guard calls this while scanning). + if (!this->scanning_) + return; + ln_ble_scan_stop(); + this->scanning_ = false; +} + +} // namespace esphome::ln882h_ble + +#endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h new file mode 100644 index 0000000000..5b4a67b566 --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -0,0 +1,153 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_LN882H_BLE + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/helpers.h" +#include "esphome/core/lock_free_queue.h" + +#include +#include + +namespace esphome::ln882h_ble { + +enum class BLEComponentState : uint8_t { + STATE_OFF = 0, + ENABLING, + ACTIVE, +}; + +/// One scan report from the controller, decoded from the SDK's rw-task event +/// (RSSI already sign-corrected). +struct BLEScanReport { + uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) + uint8_t addr_type; + bool is_scan_response; // report is a scan response (active scan) + bool scannable; // advertisement may be followed by a scan response + uint8_t data_len; // bytes valid in data[] (<= 62) + // Each report carries ONE frame — a legacy advertisement (<=31 B) or a scan + // response (<=31 B) — delivered split, exactly as the SDK reports them. The + // TRACKER merges the pair into a single frame before any consumer sees it + // (Bluedroid semantics, HubCapabilities::merges_scan_response). 62 is twice + // the legacy maximum: defensive headroom for the data_len clamp, and the + // same width as the merged framing downstream. + uint8_t data[62]; + + // EventPool contract: nothing is heap-allocated inside a report. + void release() {} +}; + +/// Consumer interface for controller scan reports. on_scan_report() always runs +/// on the ESPHome main task: reports are queued from the SDK's rw task and +/// drained by the controller's loop(), so consumers never deal with cross-task +/// state (the esp32_ble event-queue pattern). +class BLEScanListener { + public: + virtual void on_scan_report(const BLEScanReport &report) = 0; + + protected: + ~BLEScanListener() = default; // deletion via this interface is not part of the contract +}; + +// Maximum reports buffered between the rw task and loop(). Sized from the +// measured worst case, not copied: WiFi/BLE coexistence delays rw-task report +// delivery by up to ~136 ms on this device (see the tracker's pending-adv +// timeout rationale), and a busy 2.4 GHz environment delivers ~200-400 +// reports/s — a stall plus one loop() interval buffers ~30-60 reports, so 63 +// usable slots absorb it with margin. ~4.7 KB at high water, reached only +// during such stalls. +static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64; + +// Rejected frames tolerated before the first delivered report without +// declaring the scanner dead (boot-time stray extended frames are normal). +static constexpr uint16_t REJECTED_DEAD_SCANNER_THRESHOLD = 16; + +class LN882HBLE final : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + /// Bring up the LN882H BLE stack (one-time; the SDK has no teardown path). + /// Requires setup() to have run: rw_init() is handed the address resolved + /// there. Blocks ~120 ms across the SDK's settle points, so calling it from + /// loop() (enable_on_boot: false) trips the loop-blocking warning once. + void enable(); + bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; } + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + + /// Controller BLE address in the SDK's ln_bd_addr_t order: least-significant + /// octet first (the BLE/HCI convention). Reverse for printable form — the + /// byte order is in the name so platform analogs cannot be confused + /// (the bk72xx sibling exposes the same accessor). + void get_mac_lsb_first(uint8_t out[6]) const; + +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + /// Register a consumer for scan reports (delivered on the main task via loop()). + /// Storage is codegen-sized: the consumer's codegen requests a slot via + /// request_scan_listener_slot(), which emits LN882H_BLE_SCAN_LISTENER_COUNT. + void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); } +#endif + + /// Start the controller scan. Interval/window are in BLE units (0.625 ms); + /// active enables scan requests on the 1M PHY. Enables the stack first if + /// needed. Scans the legacy 1M PHY only (extended/coded PHY advertisements + /// exceed the legacy 62-byte framing consumers are sized for). + void scan_start(uint16_t interval, uint16_t window, bool active); + /// Stop the controller scan (no-op when not scanning). + void scan_stop(); + + /// Internal, SDK rw-task event-callback context: allocate a pool slot for a + /// scan report. Returns nullptr (and counts the drop) when the queue is full; + /// the callback fills the slot in place — no intermediate copy. + BLEScanReport *allocate_scan_report(); + /// Internal: hand a filled slot to the main-task queue (cannot fail — the + /// pool is sized to the queue capacity). + void push_scan_report(BLEScanReport *report); + /// Internal, rw-task context: count a report rejected by the legacy-only + /// filter, so a wrong assumption about the stack's report encoding shows up + /// in verbose logs instead of as a scanner that silently reports nothing. + void count_rejected_report() { this->rejected_reports_.fetch_add(1, std::memory_order_relaxed); } + + protected: + void resolve_mac_(); + +#ifdef LN882H_BLE_SCAN_LISTENER_COUNT + // Codegen-sized: no heap allocation, no std::vector template instantiation — + // the same StaticVector pattern as the tracker's ble_device_base listeners. + StaticVector scan_listeners_; +#endif + // Report ring: the SDK event callback (rw task) allocates a report from the + // pool, fills it and pushes the pointer; loop() pops, dispatches and releases. + // Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. + // Overflow drops the NEWEST report (allocate fails, producer counts and + // returns) — under a coexistence stall the freshest advertisements are lost + // while queued ones drain. Deliberate: matches esp32_ble, and dropping from + // the head would need consumer-side locking this design exists to avoid. + esphome::LockFreeQueue report_queue_; + // Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so + // allocate() returns nullptr before push() can fail. This prevents leaking a + // pool slot on a failed push and keeps release() off the producer path. + esphome::EventPool report_pool_; + // Reports rejected by the legacy-only filter (rw-task producer, main-task + // consumer via exchange in loop()). + std::atomic rejected_reports_{0}; + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + BLEComponentState state_{BLEComponentState::STATE_OFF}; + bool enable_on_boot_{false}; + bool scanning_{false}; // controller scan running (re-entry guard for scan_start) + // Dead-scanner diagnosis: done once a report is delivered or the one-shot + // warning has fired, whichever comes first. + bool reject_diagnosis_done_{false}; + uint32_t rejected_before_delivery_{0}; // drives the dead-scanner warning +}; + +} // namespace esphome::ln882h_ble + +#endif // USE_LN882H_BLE diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py new file mode 100644 index 0000000000..4bfaa93ab7 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -0,0 +1,172 @@ +"""LN882H BLE scanner implementing the ble_device_base BLEHub contract on +top of the ln882h_ble controller. With continuous: false nothing scans until +an explicit start_scan() call.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import ble_device_base, ln882h_ble, ota +from esphome.components.ble_device_base import automation as ble_automation +from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACTIVE, + CONF_CONTINUOUS, + CONF_DURATION, + CONF_ID, + CONF_INTERVAL, + CONF_MANUFACTURER_ID, + CONF_ON_BLE_ADVERTISE, + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_ON_BLE_SERVICE_DATA_ADVERTISE, + CONF_SERVICE_UUID, +) +from esphome.core import ID +from esphome.types import ConfigType + +CONF_LN882H_BLE_ID = "ln882h_ble_id" + +DEPENDENCIES = ["ln882x"] +AUTO_LOAD = ["ble_device_base", "ln882h_ble"] +CODEOWNERS = ["@Bl00d-B0b"] + +ble_device_base.register_hub_provider("ln882h_ble_tracker") + +ln882h_ble_tracker_ns = cg.esphome_ns.namespace("ln882h_ble_tracker") +LN882HBLETracker = ln882h_ble_tracker_ns.class_( + "LN882HBLETracker", ble_device_base.BLEHub, cg.Component +) + +StartScanAction = ln882h_ble_tracker_ns.class_("StartScanAction", automation.Action) +StopScanAction = ln882h_ble_tracker_ns.class_("StopScanAction", automation.Action) + +ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger +BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger +BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger +BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger + + +# LN882H SDK reference scan rate: 100 ms interval / 50 ms window (50 % duty). +SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema( + "100ms", window_default="50ms" +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LN882HBLETracker), + cv.GenerateID(CONF_LN882H_BLE_ID): cv.use_id(ln882h_ble.LN882HBLE), + cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, + cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema( + ESPBTAdvertiseTrigger + ), + cv.Optional( + CONF_ON_BLE_SERVICE_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEServiceDataAdvertiseTrigger, + {cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid}, + ), + cv.Optional( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE + ): ble_automation.uuid_trigger_schema( + BLEManufacturerDataAdvertiseTrigger, + {cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid}, + ), + cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema( + BLEEndOfScanTrigger + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +@automation.register_action( + "ln882h_ble_tracker.start_scan", + StartScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(LN882HBLETracker), + cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean), + } + ), + synchronous=True, +) +async def start_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + if (continuous := config.get(CONF_CONTINUOUS)) is not None: + template_ = await cg.templatable(continuous, args, cg.bool_) + cg.add(var.set_continuous(template_)) + return var + + +@automation.register_action( + "ln882h_ble_tracker.stop_scan", + StopScanAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(LN882HBLETracker), + } + ) + ), + synchronous=True, +) +async def stop_scan_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config: ConfigType) -> None: + # Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h. + cg.add_define("USE_LN882H_BLE_TRACKER") + # Compiles the shared adv + scan-response merge (the LN controller + # delivers the pair as separate reports). + cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER") + + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + parent = await cg.get_variable(config[CONF_LN882H_BLE_ID]) + cg.add(var.set_parent(parent)) + # The tracker registers itself as a controller scan listener in setup(); + # request the codegen-sized StaticVector slot for it. + ln882h_ble.request_scan_listener_slot() + + # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity) + ota.request_ota_state_listeners() + + scan = config[CONF_SCAN_PARAMETERS] + cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL]))) + cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW]))) + cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds)) + cg.add(var.set_scan_active(scan[CONF_ACTIVE])) + cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS])) + + for conf in config.get(CONF_ON_BLE_ADVERTISE, []): + await ble_automation.advertise_trigger_to_code(conf, var) + + for trigger_key, uuid_key, setter_prefix in ( + (CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"), + ( + CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, + CONF_MANUFACTURER_ID, + "set_manufacturer_uuid", + ), + ): + for conf in config.get(trigger_key, []): + await ble_automation.uuid_trigger_to_code( + conf, var, uuid_key, setter_prefix + ) + + for conf in config.get(CONF_ON_SCAN_END, []): + await ble_automation.scan_end_trigger_to_code(conf, var) diff --git a/esphome/components/ln882h_ble_tracker/automation.h b/esphome/components/ln882h_ble_tracker/automation.h new file mode 100644 index 0000000000..8b211384a0 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/automation.h @@ -0,0 +1,47 @@ +// Scan-control actions for ln882h_ble_tracker. The automation triggers are the +// neutral ble_device_base classes (ble_device_base/automation.h). + +#pragma once + +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include "esphome/core/automation.h" +#include "esphome/core/helpers.h" + +namespace esphome::ln882h_ble_tracker { + +template class StartScanAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(bool, continuous) + void play(const Ts &...x) override { + // With continuous: set, the action wins. Without it, the configured value + // is used - stop_scan() clears the runtime flag permanently, so a bare + // stop_scan/start_scan pair would otherwise never resume continuous mode. + const bool want = + this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous(); + if (this->parent_->scan_running()) { + // Same mode on a running scan is a no-op (esp32 parity): re-anchoring + // the duration window here would let a repeated action keep a one-shot + // scan alive forever. A real mode switch re-anchors so a change to + // one-shot runs a full duration from now. + if (want != this->parent_->scan_continuous()) { + this->parent_->set_scan_continuous(want); + this->parent_->restart_scan_duration(); + } + return; + } + this->parent_->set_scan_continuous(want); + this->parent_->start_scan(); + } +}; + +template class StopScanAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_scan(); } +}; + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp new file mode 100644 index 0000000000..e1083e5fbe --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -0,0 +1,257 @@ +#ifdef USE_LIBRETINY + +#include "ln882h_ble_tracker.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::ln882h_ble_tracker { + +static const char *const TAG = "ln882h_ble_tracker"; + +static constexpr float BLE_SCAN_UNIT_MS = 0.625f; + +// --------------------------------------------------------------------------- +// Component lifecycle +// --------------------------------------------------------------------------- + +void LN882HBLETracker::setup() { + // Receive the controller's scan reports; the controller queues them from the + // rw task and delivers here on the main task. + this->parent_->register_scan_listener(this); + // Merged (and unmerged) frames go to the shared dispatcher; scan_continuous_ + // is read at each delivery to decide unclaimed-device logging. + this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG); + // scan_running_ check: an on_boot start_scan action (priority 600) runs + // before this setup() (200) and enable_loop() is a no-op pre-setup — parking + // the loop here would strand that already-running scan. + if (!this->scan_continuous_ && !this->scan_running_ && !this->pending_start_) { + // Say so once: with continuous: false nothing scans until an explicit + // start_scan() — silence here reads as a broken scanner. + ESP_LOGD(TAG, "Scanning not started (continuous: false) - waiting for an explicit start_scan()"); + // Nothing to time until then; start_scan_() re-enables the loop. + this->disable_loop(); + } +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update is in flight — on the single-core LN882H the + // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker. + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif +} + +#ifdef USE_OTA_STATE_LISTENER +void LN882HBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, + ota::OTAComponent *comp) { + if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; + this->scan_running_before_ota_ = this->scan_running_; + this->stop_scan(); + } else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) { + // On success the device reboots, so restore only on a failed/aborted + // update. Continuous mode resumes via loop()'s idle branch; a one-shot + // scan that was running is restarted explicitly (bk72xx sibling parity — + // stop_scan() cleared it and nothing else would bring it back). + if (this->scan_continuous_before_ota_) { + this->scan_continuous_ = true; + this->enable_loop(); // stop_scan() disabled it; loop()'s idle branch restarts the scan + } else if (this->scan_running_before_ota_) { + this->start_scan(); + } + this->scan_continuous_before_ota_ = false; + this->scan_running_before_ota_ = false; + } +} +#endif // USE_OTA_STATE_LISTENER + +void LN882HBLETracker::loop() { + if (this->pending_start_) { + // A start_scan latched before the controller's setup(); safe now — loop() + // only runs after every component set up. + this->pending_start_ = false; + if (!this->scan_running_) { + this->start_scan_(); + } + } + // Deliver held scannable advertisements whose scan response never arrived — + // unmerged after the merger's timeout. Main-task only, like every merger call. + const uint32_t now = millis(); + if (!this->merger_.empty()) + this->merger_.sweep(now); + + if (this->scan_continuous_) { + if (!this->scan_running_) { + this->start_scan_(); + // start_scan_() re-anchors scan_period_start_ from a later millis() than + // the cached `now`; resume the period timer next iteration. + return; + } + // Period timer: once per scan_duration_ window, restart the controller scan + // and fire on_scan_end(), mirroring esp32_ble_tracker::cleanup_scan_state_(). + // The restart is the recovery path for the coexistence failure documented in + // the header. scan_start() re-enters cleanly on its own: it stops an + // in-flight scan and grants the controller's 10 ms GAPM settle before + // restarting — an explicit scan_stop() first would clear the controller's + // re-entry guard and skip that settle. + if (now - this->scan_period_start_ >= this->scan_duration_) { + ESP_LOGD(TAG, "Scan period elapsed - restarting scan"); + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + // Keep both clocks anchored to the restart: a runtime switch to + // non-continuous then times out the current period, not the whole run. + this->scan_start_time_ = now; + this->end_scan_period_(now); + } + return; + } + + // Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. + // Restart is driven externally (e.g. wifi: on_connect:). + if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) { + this->stop_scan_(); + } +} + +bool LN882HBLETracker::request_scan_mode(bool active) { + if (this->scan_active_ == active) + return true; + this->scan_active_ = active; + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); + // scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and + // no period reset: the scan logically continues, only the mode changes. + if (this->scan_running_) { + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + } + return true; +} + +void LN882HBLETracker::dump_config() { + ESP_LOGCONFIG(TAG, + "LN882H BLE Tracker:\n" + " Scan Duration: %" PRIu32 " s\n" + " Scan Interval: %.0f ms (%" PRIu16 " BLE units)\n" + " Scan Window: %.0f ms (%" PRIu16 " BLE units)\n" + " Scan Type: %s\n" + " Continuous Scanning: %s", + this->scan_duration_ / 1000, this->scan_interval_ * BLE_SCAN_UNIT_MS, this->scan_interval_, + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE", + YESNO(this->scan_continuous_)); +} + +// --------------------------------------------------------------------------- +// Adv/scan-response demux into the shared merger (ble_device_base): the LN +// controller delivers the pair as separate reports; a scannable advertisement +// is held until its scan response arrives and delivered as one merged frame. +// --------------------------------------------------------------------------- + +void LN882HBLETracker::on_scan_report(const ln882h_ble::BLEScanReport &report) { + if (report.is_scan_response) { + this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len); + return; + } + // Stash only while the scan runs: after a one-shot stop the loop is + // disabled and nothing would sweep the merger, so a late report would + // surface minutes later as a fresh advertisement. + if (this->scan_running_ && this->scan_active_ && report.scannable) { + this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len, millis()); + return; + } + this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len, + /*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG); +} + +// --------------------------------------------------------------------------- +// Public scan actions +// --------------------------------------------------------------------------- + +void LN882HBLETracker::start_scan() { + // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via + // set_scan_continuous() first, then calls start_scan() to begin scanning. + if (!this->parent_->is_ready()) { + // An on_boot automation (priority 600) runs before the controller's + // setup() has resolved the BLE MAC; scan_start() now would rw_init() the + // all-zero address and bring BLE up before WiFi. Latch; loop() applies + // the start once every setup() has run. + this->pending_start_ = true; + return; + } + if (!this->scan_running_) { + this->start_scan_(); + } +} + +void LN882HBLETracker::restart_scan_duration() { + if (!this->scan_running_) + return; + // Re-anchor only the one-shot duration clock. scan_period_start_ (the + // continuous-mode on_scan_end period) is deliberately left alone: a + // start_scan action fired more often than scan_duration_ would otherwise + // suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN + // publish) rides on that period. + this->scan_start_time_ = millis(); +} + +void LN882HBLETracker::stop_scan() { + // Cancel a start latched before the controller's setup(); without this an + // on_boot start_scan/stop_scan pair would still start at the first loop(). + this->pending_start_ = false; + this->scan_continuous_ = false; + this->stop_scan_(); +} + +// --------------------------------------------------------------------------- +// Internal scan start / stop +// --------------------------------------------------------------------------- + +void LN882HBLETracker::start_scan_() { + if (this->scan_running_) + return; + + // The controller enables the stack on first use and owns the report queue; + // this call is all the SDK interaction the tracker ever needs. + this->parent_->scan_start(this->scan_interval_, this->scan_window_, this->scan_active_); + const uint32_t now = millis(); + this->scan_running_ = true; + this->scan_start_time_ = now; + this->enable_loop(); // an idle non-continuous tracker disabled it in stop_scan_() + // Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and + // in non-continuous mode each period is an explicit start, so asymmetric logging + // would read as the scanner failing to come back up. + ESP_LOGD(TAG, "BLE scan started (%s, window=%.0fms, interval=%.0fms)", this->scan_active_ ? "active" : "passive", + this->scan_window_ * BLE_SCAN_UNIT_MS, this->scan_interval_ * BLE_SCAN_UNIT_MS); + // Re-anchor the on_scan_end period to every successful start, so a restart + // later than scan_duration (e.g. a failed OTA restoring continuous mode) + // does not fire on_scan_end before an advertisement can arrive. + this->scan_period_start_ = now; +} + +void LN882HBLETracker::stop_scan_() { + if (!this->scan_running_) + return; + this->parent_->scan_stop(); + this->scan_running_ = false; + // DEBUG like start_scan_() — a per-period stop at INFO would read as the + // scanner failing to come back up. + ESP_LOGD(TAG, "BLE scan stopped"); + this->end_scan_period_(millis()); // also resets the period clock so on_scan_end does not double-fire + // scan_running_ re-check: an on_scan_end automation runs synchronously inside + // end_scan_period_() and may have called start_scan() — parking the loop then + // would leave the radio scanning with no period timing or pending-adv sweep. + if (!this->scan_continuous_ && !this->scan_running_) { + // Nothing left to time; start_scan_() re-enables the loop. + this->disable_loop(); + } +} + +// Close a scan period: deliver held advertisements whose scan response never +// came (unmerged) BEFORE on_scan_end fires, then re-anchor the period clock. +void LN882HBLETracker::end_scan_period_(uint32_t now) { + this->merger_.flush(); + this->dispatcher_.on_scan_end(); + this->scan_period_start_ = now; +} + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h new file mode 100644 index 0000000000..dc42aebce9 --- /dev/null +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -0,0 +1,144 @@ +// BLE scanner for LN882H: implements ble_device_base::BLEHub on top of the +// ln882h_ble controller (which owns all SDK calls and delivers scan reports on +// the main task). Scan policy lives here: parameters, period timers with +// per-period restart, and the adv+scan-response merge. + +#pragma once + +#ifdef USE_LIBRETINY + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/ble_device_base/ble_hub.h" +#include "esphome/components/ble_device_base/scan_response_merger.h" +#include "esphome/components/ln882h_ble/ln882h_ble.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include + +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + +namespace esphome::ln882h_ble_tracker { + +// --------------------------------------------------------------------------- +// LN882HBLETracker +// --------------------------------------------------------------------------- + +class LN882HBLETracker : public Component, + public Parented, + public ln882h_ble::BLEScanListener +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ + public: + // ---- ESPHome Component ---- + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_OTA_STATE_LISTENER + // Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention); + // mirrors esp32_ble_tracker. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + + // ---- YAML configuration setters ---- + void set_scan_active(bool scan_active) { this->scan_active_ = scan_active; } + void set_scan_interval(uint16_t scan_interval) { this->scan_interval_ = scan_interval; } + void set_scan_window(uint16_t scan_window) { this->scan_window_ = scan_window; } + void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } + /// Set from YAML (scan_parameters.continuous); also the value + /// configured_continuous() reports and a bare start_scan action restores. + void set_configured_continuous(bool scan_continuous) { + this->scan_continuous_ = scan_continuous; + this->scan_continuous_configured_ = scan_continuous; + } + /// Runtime control (esp32_ble_tracker lambda parity): does not change the + /// configured value, so configured_continuous() still reports what YAML + /// asked for. + void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; } + bool scan_continuous() const { return this->scan_continuous_; } + bool configured_continuous() const { return this->scan_continuous_configured_; } + /// Re-anchor the one-shot duration clock of a running scan to now — used + /// when an action changes the scan mode without stopping the radio. The + /// continuous-mode on_scan_end period is deliberately not touched. + void restart_scan_duration(); + + // ---- Public scan control ---- + // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). + void start_scan(); + void stop_scan(); + + // ---- ble_device_base::BLEHub contract ---- + void register_listener(ble_device_base::ESPBTDeviceListener *listener) { + this->dispatcher_.register_listener(listener); + } + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->dispatcher_.set_raw_advertisement_callback(callback); + } + static constexpr ble_device_base::HubCapabilities get_capabilities() { + // The LN882H controller supports active scanning; adv + scan response arrive + // as separate reports and are merged by this tracker (Bluedroid semantics). + // The SDK's GATT client is not exposed. + // scan_mode_switch: request_scan_mode() is implemented (restart-if-running). + return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true}; + } + // The controller stores the address LSB-first (BLE convention); the contract + // wants printable (MSB-first) order. + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; + this->parent_->get_mac_lsb_first(mac); + for (int i = 0; i < 6; i++) + out[i] = mac[5 - i]; + } + bool scan_running() { return this->scan_running_; } + bool scan_active() { return this->scan_active_; } + bool request_scan_mode(bool active); + + // ---- ln882h_ble::BLEScanListener ---- + // Delivered by the controller's loop() on the ESPHome main task — the + // rw-task → main-task handoff already happened in the controller's queue. + // Demultiplexes advertisements vs scan responses and drives the merge. + void on_scan_report(const ln882h_ble::BLEScanReport &report) override; + + protected: + void start_scan_(); + void stop_scan_(); + // Close a scan period: flush held advertisements (unmerged) BEFORE + // on_scan_end fires, then re-anchor the period clock to `now`. + void end_scan_period_(uint32_t now); + + bool scan_running_{false}; + bool scan_active_{false}; + // Defaults are the LN882H SDK's recommended scan parameters + // (ln_ble_scan.h: SCAN_INTERVAL_DEF 0xA0, SCAN_WINDOW_DEF 0x50 → 50 % duty). + // uint16_t matches the controller's scan_start() parameters. + uint16_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms (SDK SCAN_INTERVAL_DEF) + uint16_t scan_window_{80}; // 80 × 0.625 ms = 50 ms (SDK SCAN_WINDOW_DEF; 50/100 = 50 %) + uint32_t scan_duration_{300000}; + bool scan_continuous_{true}; + bool pending_start_{false}; // start_scan() latched before the controller's setup() + bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure + bool scan_running_before_ota_{false}; // one-shot scan running at OTA start, restarted on OTA failure +#endif + uint32_t scan_start_time_{0}; + + // Shared adv + scan-response merge and frame dispatch (ble_device_base). + // All calls run on the main task (the controller queue already crossed + // tasks); the merger is clocked by millis() throughout this tracker. + ble_device_base::ScanResponseMerger merger_; + ble_device_base::AdvDispatcher dispatcher_; + + uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() +}; + +} // namespace esphome::ln882h_ble_tracker + +#endif // USE_LIBRETINY diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index 9c91827522..b4e179c4a9 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -28,6 +28,8 @@ from esphome.components.libretiny.const import ( LibreTinyComponent, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from .boards import LN882X_BOARD_PINS, LN882X_BOARDS @@ -45,25 +47,29 @@ COMPONENT_DATA = LibreTinyComponent( ) -def _set_core_data(config): +def _set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_LIBRETINY] = {} CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA] = COMPONENT_DATA return config -CONFIG_SCHEMA = libretiny.BASE_SCHEMA +# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared +# by every LibreTiny platform, and prepending this platform's _set_core_data +# onto the shared object would run it for every platform's validation once two +# platform modules are imported in one process (device-builder, tests). +CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({}) PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA CONFIG_SCHEMA.prepend_extra(_set_core_data) -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: return await libretiny.component_to_code(config) @pins.PIN_SCHEMA_REGISTRY.register("ln882x", PIN_SCHEMA) -async def pin_to_code(config): +async def pin_to_code(config: ConfigType) -> MockObj: return await libretiny.gpio.component_pin_to_code(config) 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/__init__.py b/esphome/components/lock/__init__.py index 0a8ad58bc2..a4a7b5237d 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,13 +12,14 @@ from esphome.const import ( CONF_ON_UNLOCK, CONF_WEB_SERVER, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( entity_duplicate_validator, queue_entity_register, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType +from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -102,7 +103,7 @@ _CALLBACK_AUTOMATIONS = ( @setup_entity("lock") -async def _setup_lock_core(var, config): +async def _setup_lock_core(var: MockObj, config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id := config.get(CONF_MQTT_ID): @@ -113,7 +114,7 @@ async def _setup_lock_core(var, config): await web_server.add_entity_config(var, web_server_config) -async def register_lock(var, config): +async def register_lock(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) queue_entity_register("lock", config) @@ -121,7 +122,7 @@ async def register_lock(var, config): await _setup_lock_core(var, config) -async def new_lock(config, *args): +async def new_lock(config: ConfigType, *args: SafeExpType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], *args) await register_lock(var, config) return var @@ -143,23 +144,38 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True ) -async def lock_action_to_code(config, action_id, template_arg, args): +async def lock_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) @automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_on_to_code(config, condition_id, template_arg, args): +async def lock_is_on_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, True) @automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA) -async def lock_is_off_to_code(config, condition_id, template_arg, args): +async def lock_is_off_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(condition_id, template_arg, paren, False) @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(lock_ns.using) 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 9d7dc8d92c..07b8b03084 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -1,4 +1,5 @@ import re +from typing import Any from esphome import automation from esphome.automation import LambdaAction, StatelessLambdaAction @@ -11,9 +12,12 @@ from esphome.components.esp32 import ( VARIANT_ESP32C6, VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, add_idf_sdkconfig_option, get_esp32_variant, require_usb_serial_jtag_secondary, @@ -51,11 +55,12 @@ from esphome.const import ( PLATFORM_ESP8266, PLATFORM_LN882X, PLATFORM_NRF52, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_RTL87XX, PlatformFramework, ) -from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -113,9 +118,12 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32H21: [UART0, UART1, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32S2: [UART0, UART1, USB_CDC], VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32S31: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -148,7 +156,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, @@ -158,14 +166,14 @@ HARDWARE_UART_TO_SERIAL = { is_log_level = cv.one_of(*LOG_LEVELS, upper=True) -def uart_selection(value): +def uart_selection(value: Any) -> str: if CORE.is_esp32: variant = get_esp32_variant() if variant in UART_SELECTION_ESP32: 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() @@ -181,7 +189,7 @@ def uart_selection(value): raise NotImplementedError -def validate_local_no_higher_than_global(config): +def validate_local_no_higher_than_global(config: ConfigType) -> ConfigType: global_level = config[CONF_LEVEL] global_level_index = LOG_LEVEL_SEVERITY.index(global_level) errs = [] @@ -198,7 +206,7 @@ def validate_local_no_higher_than_global(config): return config -def validate_initial_no_higher_than_global(config): +def validate_initial_no_higher_than_global(config: ConfigType) -> ConfigType: if initial_level := config.get(CONF_INITIAL_LEVEL): global_level = config[CONF_LEVEL] if LOG_LEVEL_SEVERITY.index(initial_level) > LOG_LEVEL_SEVERITY.index( @@ -211,7 +219,7 @@ def validate_initial_no_higher_than_global(config): return config -def validate_wait_for_cdc(config): +def validate_wait_for_cdc(config: ConfigType) -> ConfigType: if config.get(CONF_WAIT_FOR_CDC) and config.get(CONF_HARDWARE_UART) != USB_CDC: raise cv.Invalid("wait_for_cdc requires hardware_uart: USB_CDC") return config @@ -270,10 +278,13 @@ CONFIG_SCHEMA = cv.All( esp32_c6=USB_SERIAL_JTAG, esp32_c61=USB_SERIAL_JTAG, esp32_h2=USB_SERIAL_JTAG, + esp32_h4=USB_SERIAL_JTAG, + esp32_h21=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, esp32_s2=USB_CDC, esp32_s3=USB_SERIAL_JTAG, - rp2040=USB_CDC, + esp32_s31=USB_SERIAL_JTAG, + rp2=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, rtl87xx=DEFAULT, @@ -283,7 +294,7 @@ CONFIG_SCHEMA = cv.All( [ PLATFORM_ESP8266, PLATFORM_ESP32, - PLATFORM_RP2040, + PLATFORM_RP2, PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RTL87XX, @@ -401,18 +412,20 @@ async def _late_logger_init(config: ConfigType) -> None: from esphome.components.esp8266.const import enable_serial, enable_serial1 hw_uart = config.get(CONF_HARDWARE_UART, UART0) - if has_serial_logging and hw_uart in (UART0, UART0_SWAP): + if not has_serial_logging: + # No serial logging: stub out ROM ets_putc so stray output (newlib + # stdout, lwIP diagnostics) cannot block on a slow or shared UART0. + # ets_putc always writes to the physical UART and cannot be disabled + # through uart_set_debug(); see __wrap_ets_putc in logger_esp8266.cpp. + cg.add_build_flag("-Wl,--wrap=ets_putc") + elif hw_uart in (UART0, UART0_SWAP): cg.add_define("USE_ESP8266_LOGGER_SERIAL") enable_serial() - elif has_serial_logging and hw_uart == UART1: + elif hw_uart == UART1: 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) ] @@ -452,7 +465,11 @@ async def _late_logger_init(config: ConfigType) -> None: cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") # USB Serial JTAG code is compiled when platform supports it. # Enable secondary USB serial JTAG console so the VFS functions are available. - if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG: + if ( + CORE.is_esp32 + and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG + and has_serial_logging + ): require_usb_serial_jtag_secondary() require_vfs_termios() except cv.Invalid: @@ -503,18 +520,18 @@ async def _late_logger_init(config: ConfigType) -> None: CORE.add_job(final_step) -def validate_printf(value): +def validate_printf(value: ConfigType) -> ConfigType: # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python cfmt = r""" - ( # start of capture group 1 - % # literal "%" - (?:[-+0 #]{0,5}) # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + (?:[-+0 #]{0,5}) # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) - """ # noqa + """ matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE) if len(matches) != len(value[CONF_ARGS]): raise cv.Invalid( @@ -544,7 +561,12 @@ LOGGER_LOG_ACTION_SCHEMA = cv.All( @automation.register_action( CONF_LOGGER_LOG, LambdaAction, LOGGER_LOG_ACTION_SCHEMA, synchronous=True ) -async def logger_log_action_to_code(config, action_id, template_arg, args): +async def logger_log_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: esp_log = LOG_LEVEL_TO_ESP_LOG[config[CONF_LEVEL]] args_ = [cg.RawExpression(str(x)) for x in config[CONF_ARGS]] @@ -569,7 +591,12 @@ async def logger_log_action_to_code(config, action_id, template_arg, args): ), synchronous=True, ) -async def logger_set_level_to_code(config, action_id, template_arg, args): +async def logger_set_level_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: level = LOG_LEVELS[config[CONF_LEVEL]] logger = await cg.get_variable(config[CONF_LOGGER_ID]) if tag := config.get(CONF_TAG): @@ -592,7 +619,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, @@ -641,7 +668,7 @@ def request_log_listener() -> None: @coroutine_with_priority(CoroPriority.FINAL) -async def final_step(): +async def final_step() -> None: """Final code generation step to configure optional logger features.""" domain_data = CORE.data.get(DOMAIN, {}) if domain_data.get(KEY_LEVEL_LISTENERS, False): diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index a035525101..bfc005070e 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -175,6 +175,10 @@ void Logger::process_messages_() { #ifdef USE_ESPHOME_TASK_LOG_BUFFER // Process any buffered messages when available if (this->log_buffer_.has_messages()) { + // Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering + // and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here. + // Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path. + RecursionGuard guard(this->main_task_recursion_guard_); logger::TaskLogBuffer::LogMessage *message; uint16_t text_length; while (this->log_buffer_.borrow_message_main_loop(message, text_length)) { @@ -197,17 +201,10 @@ void Logger::process_messages_() { #endif // USE_ESPHOME_TASK_LOG_BUFFER } -void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS 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) -UARTSelection Logger::get_uart() const { return this->uart_; } -#endif - -float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } - // Log level strings - packed into flash on ESP8266, indexed by log level (0-7) PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE"); diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index 784cbea67e..9c26814f7e 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. @@ -148,7 +148,7 @@ class Logger final : public Component { void loop() override; #endif /// Manually set the baud rate for serial, set to 0 to disable. - void set_baud_rate(uint32_t baud_rate); + void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; } uint32_t get_baud_rate() const { return baud_rate_; } #if defined(USE_ARDUINO) && !defined(USE_ESP32) Stream *get_hw_serial() const { return hw_serial_; } @@ -160,10 +160,10 @@ 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; + UARTSelection get_uart() const { return this->uart_; } #endif /// Set the default log level for this logger. @@ -197,7 +197,7 @@ class Logger final : public Component { void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); } #endif - float get_setup_priority() const override; + float get_setup_priority() const override { return setup_priority::BUS + 500.0f; } void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT #ifdef USE_STORE_LOG_STR_IN_FLASH @@ -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 Trigger -#ifdef USE_LOGGER_USB_SERIAL_JTAG +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) #include @@ -29,8 +29,8 @@ namespace esphome::logger { static const char *const TAG = "logger"; -#ifdef USE_LOGGER_USB_SERIAL_JTAG -static void init_usb_serial_jtag_() { +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG +static void init_usb_serial_jtag() { setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0) @@ -108,7 +108,9 @@ void Logger::pre_setup() { #endif #ifdef USE_LOGGER_USB_SERIAL_JTAG case UART_SELECTION_USB_SERIAL_JTAG: - init_usb_serial_jtag_(); +#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG + init_usb_serial_jtag(); +#endif break; #endif } diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5797b03ba7..ac71ba8e3b 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -49,4 +49,17 @@ const LogString *Logger::get_uart_selection_() { } } // namespace esphome::logger + +#if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) +// With serial logging disabled, ROM ets_putc still writes to the physical UART0 +// at whatever baud rate a uart bus configured there; uart_set_debug(UART_NO) +// only silences the installable putc1 hook, not ets_putc itself. Blocking +// writes at a low baud rate (for example 4800 for a power monitoring chip) can +// starve the soft watchdog. All linked callers (newlib stdout, lwIP +// diagnostics, postmortem dumps) are redirected here by -Wl,--wrap=ets_putc. +// IRAM_ATTR because the ROM original is callable with the flash cache +// disabled (for example from newlib's _write_r, which is placed in IRAM). +extern "C" void IRAM_ATTR __wrap_ets_putc(char) {} +#endif + #endif diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index fe094f6e9e..708ab883e7 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -21,6 +21,7 @@ void HOT Logger::write_msg_(const char *msg, uint16_t len) { // Single write for everything fwrite(buffer, 1, pos, stdout); + fflush(stdout); } void Logger::pre_setup() { global_logger = this; } diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2.cpp similarity index 85% rename from esphome/components/logger/logger_rp2040.cpp rename to esphome/components/logger/logger_rp2.cpp index a0215ec9ec..b949d7ee16 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -1,8 +1,8 @@ -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "logger.h" #include "esphome/core/defines.h" -#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 #include "esphome/core/log.h" @@ -29,8 +29,8 @@ void Logger::pre_setup() { } global_logger = this; ESP_LOGI(TAG, "Log initialized"); -#ifdef USE_RP2040_CRASH_HANDLER - rp2040::crash_handler_log(); +#ifdef USE_RP2_CRASH_HANDLER + rp2::crash_handler_log(); #endif } @@ -50,4 +50,4 @@ const LogString *Logger::get_uart_selection_() { } } // namespace esphome::logger -#endif // USE_RP2040 +#endif // USE_RP2 diff --git a/esphome/components/logger/logger_rp2040.h b/esphome/components/logger/logger_rp2.h similarity index 94% rename from esphome/components/logger/logger_rp2040.h rename to esphome/components/logger/logger_rp2.h index 604d8b8ca6..6ed25b298b 100644 --- a/esphome/components/logger/logger_rp2040.h +++ b/esphome/components/logger/logger_rp2.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_RP2040 +#ifdef USE_RP2 #include "esphome/core/helpers.h" namespace esphome::logger { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index 240bcc57c7..b7884b702b 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -57,7 +57,7 @@ void Logger::pre_setup() { if (this->baud_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/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 6ce663978e..00f67422f3 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -4,6 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented +from esphome.types import ConfigType from .. import ( CONF_LOGGER_ID, @@ -26,7 +27,7 @@ CONFIG_SCHEMA = select.select_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) diff --git a/esphome/components/logger/task_log_buffer_libretiny.cpp b/esphome/components/logger/task_log_buffer_libretiny.cpp index b6d6b22ab5..5cde18d19e 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.cpp +++ b/esphome/components/logger/task_log_buffer_libretiny.cpp @@ -20,7 +20,7 @@ TaskLogBuffer::~TaskLogBuffer() { } } -size_t TaskLogBuffer::available_contiguous_space() const { +size_t TaskLogBuffer::available_contiguous_space_() const { if (this->head_ >= this->tail_) { // head is ahead of or equal to tail // Available space is from head to end, plus from start to tail @@ -81,7 +81,7 @@ void TaskLogBuffer::release_message_main_loop() { this->tail_ = 0; } - this->message_count_--; + this->message_count_ = this->message_count_ - 1; this->current_message_size_ = 0; xSemaphoreGive(this->mutex_); @@ -117,7 +117,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Check if we have enough contiguous space - size_t contiguous = this->available_contiguous_space(); + size_t contiguous = this->available_contiguous_space_(); if (contiguous < total_size) { // Not enough contiguous space at end @@ -128,9 +128,9 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin } // Need at least enough space to safely write padding marker (level field is at end of struct) - constexpr size_t PADDING_MARKER_MIN_SPACE = offsetof(LogMessage, level) + 1; + constexpr size_t padding_marker_min_space = offsetof(LogMessage, level) + 1; - if (space_at_start >= total_size && this->head_ > 0 && contiguous >= PADDING_MARKER_MIN_SPACE) { + if (space_at_start >= total_size && this->head_ > 0 && contiguous >= padding_marker_min_space) { // Add padding marker (set level field to indicate this is padding, not a real message) LogMessage *padding = reinterpret_cast(this->storage_ + this->head_); padding->level = PADDING_MARKER_LEVEL; @@ -180,7 +180,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin this->head_ = 0; } - this->message_count_++; + this->message_count_ = this->message_count_ + 1; xSemaphoreGive(this->mutex_); return true; diff --git a/esphome/components/logger/task_log_buffer_libretiny.h b/esphome/components/logger/task_log_buffer_libretiny.h index b42894502a..ce469a1a18 100644 --- a/esphome/components/logger/task_log_buffer_libretiny.h +++ b/esphome/components/logger/task_log_buffer_libretiny.h @@ -84,7 +84,7 @@ class TaskLogBuffer { static inline size_t message_total_size(size_t text_length) { return sizeof(LogMessage) + text_length + 1; } // Calculate available contiguous space at write position - size_t available_contiguous_space() const; + size_t available_contiguous_space_() const; uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) size_t head_{0}; // Write position 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/lps22/sensor.py b/esphome/components/lps22/sensor.py index 08e97ee7b7..2eec2c586c 100644 --- a/esphome/components/lps22/sensor.py +++ b/esphome/components/lps22/sensor.py @@ -12,6 +12,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_HECTOPASCAL, ) +from esphome.types import ConfigType CODEOWNERS = ["@nagisa"] DEPENDENCIES = ["i2c"] @@ -44,7 +45,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lsm6ds/__init__.py b/esphome/components/lsm6ds/__init__.py new file mode 100644 index 0000000000..b1044276a1 --- /dev/null +++ b/esphome/components/lsm6ds/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@clydebarrow"] + +CONF_LSM6DS_ID = "lsm6ds_id" +# C++ namespace / class + +lsm6ds_ns = cg.esphome_ns.namespace("lsm6ds") +LSM6DSComponent = lsm6ds_ns.class_( + "LSM6DSComponent", + MotionComponent, + i2c.I2CDevice, +) diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp new file mode 100644 index 0000000000..efdd241578 --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -0,0 +1,203 @@ +#include "lsm6ds.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome::lsm6ds { + +static const char *const TAG = "lsm6ds"; + +static const struct { + uint8_t who_am_i; + const char *const name; +} CHIP_IDS[] = {{0x69, "LSMDSO"}, {0x6A, "LSM6DS3"}}; + +void LSM6DSComponent::setup() { + MotionComponent::setup(); + uint8_t who_am_i = 0; + if (this->read_register(LSM6DS_REG_WHO_AM_I, &who_am_i, 1) != i2c::ERROR_OK) { + ESP_LOGE(TAG, "Failed to read WHO_AM_I — check wiring and I2C address"); + this->mark_failed(); + return; + } + const char *chip_name = nullptr; + for (const auto &chip : CHIP_IDS) { + if (chip.who_am_i == who_am_i) { + chip_name = chip.name; + break; + } + } + if (chip_name == nullptr) { + ESP_LOGE(TAG, "Unknown WHO_AM_I: 0x%02X", who_am_i); + this->mark_failed(LOG_STR("Unknown WHO_AM_I value")); + return; + } + ESP_LOGD(TAG, "Found %s (WHO_AM_I = 0x%02X)", chip_name, who_am_i); + this->chip_name_ = chip_name; + + // 2. Software reset — clears all registers to defaults + if (this->write_register(LSM6DS_REG_CTRL3_C, &CTRL3_C_SW_RESET, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Software reset failed")); + return; + } + // Datasheet: reset bit self-clears after boot (typ. 50 µs); + delay(2); + + // 3. Enable auto-increment and block data update (BDU). + // BDU prevents reading a high-byte from one sample and a low-byte from the next. + // IF_INC is set by default after reset but we set it explicitly for clarity. + uint8_t ctrl3 = CTRL3_C_IF_INC | CTRL3_C_BDU; + if (this->write_register(LSM6DS_REG_CTRL3_C, &ctrl3, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Config failed")); + return; + } + + // 4. Configure accelerometer: ODR in bits[7:4], FS in bits[3:2] + // Anti-aliasing filter bandwidth is left at power-on default (bits[1:0] = 00 = ODR/2). + uint8_t ctrl1_xl = (uint8_t) (this->accel_odr_ << 4) | (uint8_t) (this->accel_range_ << 2); + if (this->write_register(LSM6DS_REG_CTRL1_XL, &ctrl1_xl, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure accelerometer")); + return; + } + + // 5. Configure gyroscope: ODR in bits[7:4], FS_G + FS_125 in bits[3:0] + // For ±125 dps: FS_G[2:1]=00 and FS_125(bit1)=1, so gyro_range_ encodes the full nibble. + uint8_t ctrl2_g = (uint8_t) (this->gyro_odr_ << 4) | (uint8_t) (this->gyro_range_); + if (this->write_register(LSM6DS_REG_CTRL2_G, &ctrl2_g, 1) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to configure gyroscope")); + return; + } + + // 6. Ensure accelerometer is in high-performance mode (CTRL6_C bit 4 = XL_HM_MODE = 0) + // and gyroscope is in high-performance mode (CTRL7_G bit 7 = G_HM_MODE = 0). + // Both default to 0 (high-performance) after reset, but write explicitly. + uint8_t zero = 0x00; + if (this->write_register(LSM6DS_REG_CTRL6_C, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + if (this->write_register(LSM6DS_REG_CTRL7_G, &zero, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } +} + +void LSM6DSComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "LSM6DS IMU:\n" + " Chip type: %s\n", + this->chip_name_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + // Accel range — index into the sensitivity table (datasheet Table 3) + static const char *const ACCEL_RANGE_STR[] = {"±2g", "±16g", "±4g", "±8g"}; + + const char *gyro_str; + switch (this->gyro_range_) { + case LSM6DS_GYRO_RANGE_125: + gyro_str = "±125dps"; + break; + case LSM6DS_GYRO_RANGE_250: + gyro_str = "±250dps"; + break; + case LSM6DS_GYRO_RANGE_500: + gyro_str = "±500dps"; + break; + case LSM6DS_GYRO_RANGE_1000: + gyro_str = "±1000dps"; + break; + case LSM6DS_GYRO_RANGE_2000: + gyro_str = "±2000dps"; + break; + default: + gyro_str = "unknown"; + break; + } + auto accel_odr = this->accel_odr_ == 0 ? 0 : 13 * (1 << (this->accel_odr_ - 1)); + auto gyro_odr = this->gyro_odr_ == 0 ? 0 : 13 * (1 << (this->gyro_odr_ - 1)); + ESP_LOGCONFIG(TAG, + " Accel range : %s\n" + " Accel data rate : %dHz\n" + " Gyro range : %s\n" + " Gyro data rate : %dHz", + ACCEL_RANGE_STR[this->accel_range_], accel_odr, gyro_str, gyro_odr); +} + +// update_data() +// Called by MotionComponent::update() on each polling interval. +// Reads gyro XYZ and accel XYZ in a single 12-byte burst (registers 0x22–0x2D). +// Values are in g (accel) and °/s (gyro) — MotionComponent handles axis mapping +// and sensor publishing. + +bool LSM6DSComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + // Single burst: gyro X/Y/Z (0x22–0x27) then accel X/Y/Z (0x28–0x2D) + uint8_t raw[LSM6DS_BURST_LEN]; + if (!this->read_bytes(LSM6DS_REG_OUTX_L_G, raw, LSM6DS_BURST_LEN)) { + this->status_set_error(LOG_STR("Failed to read IMU data")); + return false; + } + this->status_clear_error(); + + // Gyroscope + // Sensitivity (mdps/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mdps → dps (°/s). + static constexpr float GYRO_SCALE[] = { + 8.75e-3f, // 0x00 — ±250 dps + 8.75e-3f, // 0x01 — unused (maps to 250 as fallback) + 4.375e-3f, // 0x02 — ±125 dps (FS_125 bit set) + 8.75e-3f, // 0x03 — unused + 17.50e-3f, // 0x04 — ±500 dps + 17.50e-3f, // 0x05 — unused + 8.75e-3f, // 0x06 — unused + 8.75e-3f, // 0x07 — unused + 35.0e-3f, // 0x08 — ±1000 dps + 35.0e-3f, // 0x09 — unused + 17.50e-3f, // 0x0A — unused + 17.50e-3f, // 0x0B — unused + 70.0e-3f, // 0x0C — ±2000 dps + }; + float gyro_scale = GYRO_SCALE[this->gyro_range_]; + + data.angular_rate[motion::X_AXIS] = (int16_t) ((raw[1] << 8) | raw[0]) * gyro_scale; + data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw[3] << 8) | raw[2]) * gyro_scale; + data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw[5] << 8) | raw[4]) * gyro_scale; + + // Accelerometer + // Sensitivity (mg/LSB) from datasheet Table 3. + // Multiply by 1e-3 to convert mg → g. + // Note: FS_XL register values are non-monotonic (0=2g, 1=16g, 2=4g, 3=8g). + static constexpr float ACCEL_SCALE[] = { + 0.061e-3f, // 0x00 — ±2g + 0.488e-3f, // 0x01 — ±16g + 0.122e-3f, // 0x02 — ±4g + 0.244e-3f, // 0x03 — ±8g + }; + float accel_scale = ACCEL_SCALE[this->accel_range_]; + + data.acceleration[motion::X_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 1] << 8) | raw[LSM6DS_ACCEL_OFFSET + 0]) * accel_scale; + data.acceleration[motion::Y_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 3] << 8) | raw[LSM6DS_ACCEL_OFFSET + 2]) * accel_scale; + data.acceleration[motion::Z_AXIS] = + (int16_t) ((raw[LSM6DS_ACCEL_OFFSET + 5] << 8) | raw[LSM6DS_ACCEL_OFFSET + 4]) * accel_scale; + + // Temperature (lazy — only read if a listener is registered) + // Kept as a separate 2-byte read to avoid extending the burst to 14 bytes when + // temperature is not needed. + // Formula: T(°C) = (raw / 256.0) + 25.0 (datasheet Table 90, OUT_TEMP register) + if (!this->temperature_callback_.empty()) { + uint8_t raw_t[2]; + if (this->read_bytes(LSM6DS_REG_OUT_TEMP_L, raw_t, 2)) { + int16_t temp_raw = (int16_t) ((raw_t[1] << 8) | raw_t[0]); + float temperature = (temp_raw / 256.0f) + 25.0f; + this->temperature_callback_.call(temperature); + } + } + + return true; +} + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/lsm6ds.h b/esphome/components/lsm6ds/lsm6ds.h new file mode 100644 index 0000000000..47d6f55939 --- /dev/null +++ b/esphome/components/lsm6ds/lsm6ds.h @@ -0,0 +1,111 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/motion/motion_component.h" + +namespace esphome::lsm6ds { + +// ── Register map (datasheet DocID030071 Rev 3, Table 19) ──────────────────── +static const uint8_t LSM6DS_REG_WHO_AM_I = 0x0F; +static const uint8_t LSM6DS_REG_CTRL1_XL = 0x10; // Accel ODR + FS +static const uint8_t LSM6DS_REG_CTRL2_G = 0x11; // Gyro ODR + FS +static const uint8_t LSM6DS_REG_CTRL3_C = 0x12; // SW_RESET, BDU, IF_INC +static const uint8_t LSM6DS_REG_CTRL6_C = 0x15; // Accel HP disable, Gyro LPF1 +static const uint8_t LSM6DS_REG_CTRL7_G = 0x16; // Gyro HP disable +static const uint8_t LSM6DS_REG_STATUS = 0x1E; // XLDA, GDA, TDA +static const uint8_t LSM6DS_REG_OUT_TEMP_L = 0x20; // Temperature LSB +static const uint8_t LSM6DS_REG_OUTX_L_G = 0x22; // Gyro X LSB (burst start) +static const uint8_t LSM6DS_REG_OUTX_L_XL = 0x28; // Accel X LSB + +// Burst read from 0x22 to 0x2D inclusive: gyro XYZ (6 bytes) + accel XYZ (6 bytes) +static const uint8_t LSM6DS_BURST_LEN = 12; +static const uint8_t LSM6DS_ACCEL_OFFSET = 6; // 0x28 - 0x22 + +// ── CTRL3_C bit fields ─────────────────────────────────────────────────────── +static const uint8_t CTRL3_C_SW_RESET = (1 << 0); +static const uint8_t CTRL3_C_IF_INC = (1 << 2); // auto-increment address on burst (default 1) +static const uint8_t CTRL3_C_BDU = (1 << 6); // block data update + +// ── Accelerometer full-scale range ────────────────────────────────────────── +// CTRL1_XL bits [3:2] — FS_XL[1:0] +// Note: 0x01 = ±16g is intentional per Table 52 — the mapping is non-monotonic +enum LSM6DSAccelRange : uint8_t { + LSM6DS_ACCEL_RANGE_2G = 0x00, // ±2 g, 0.061 mg/LSB + LSM6DS_ACCEL_RANGE_16G = 0x01, // ±16 g, 0.488 mg/LSB + LSM6DS_ACCEL_RANGE_4G = 0x02, // ±4 g, 0.122 mg/LSB + LSM6DS_ACCEL_RANGE_8G = 0x03, // ±8 g, 0.244 mg/LSB +}; + +// ── Accelerometer output data rate ────────────────────────────────────────── +// CTRL1_XL bits [7:4] — ODR_XL[3:0] +enum LSM6DSAccelODR : uint8_t { + LSM6DS_ACCEL_ODR_OFF = 0x00, + LSM6DS_ACCEL_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_ACCEL_ODR_26 = 0x02, // 26 Hz + LSM6DS_ACCEL_ODR_52 = 0x03, // 52 Hz + LSM6DS_ACCEL_ODR_104 = 0x04, // 104 Hz + LSM6DS_ACCEL_ODR_208 = 0x05, // 208 Hz + LSM6DS_ACCEL_ODR_416 = 0x06, // 416 Hz + LSM6DS_ACCEL_ODR_833 = 0x07, // 833 Hz + LSM6DS_ACCEL_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_ACCEL_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_ACCEL_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Gyroscope full-scale range ─────────────────────────────────────────────── +// CTRL2_G bits [3:0] — FS_G[2:1] and FS_125 (bit 1) +// The FS_125 bit (bit 1) enables the ±125 dps range independently of FS_G. +// For all other ranges, bits [3:2] select the range and bit 1 = 0. +enum LSM6DSGyroRange : uint8_t { + LSM6DS_GYRO_RANGE_125 = 0x02, // ±125 dps, 4.375 mdps/LSB (FS_125=1) + LSM6DS_GYRO_RANGE_250 = 0x00, // ±250 dps, 8.75 mdps/LSB + LSM6DS_GYRO_RANGE_500 = 0x04, // ±500 dps, 17.50 mdps/LSB + LSM6DS_GYRO_RANGE_1000 = 0x08, // ±1000 dps, 35 mdps/LSB + LSM6DS_GYRO_RANGE_2000 = 0x0C, // ±2000 dps, 70 mdps/LSB +}; + +// ── Gyroscope output data rate ─────────────────────────────────────────────── +// CTRL2_G bits [7:4] — ODR_G[3:0] +enum LSM6DSGyroODR : uint8_t { + LSM6DS_GYRO_ODR_OFF = 0x00, + LSM6DS_GYRO_ODR_12_5 = 0x01, // 12.5 Hz + LSM6DS_GYRO_ODR_26 = 0x02, // 26 Hz + LSM6DS_GYRO_ODR_52 = 0x03, // 52 Hz + LSM6DS_GYRO_ODR_104 = 0x04, // 104 Hz + LSM6DS_GYRO_ODR_208 = 0x05, // 208 Hz + LSM6DS_GYRO_ODR_416 = 0x06, // 416 Hz + LSM6DS_GYRO_ODR_833 = 0x07, // 833 Hz + LSM6DS_GYRO_ODR_1666 = 0x08, // 1666 Hz + LSM6DS_GYRO_ODR_3332 = 0x09, // 3332 Hz + LSM6DS_GYRO_ODR_6664 = 0x0A, // 6664 Hz +}; + +// ── Main component class ───────────────────────────────────────────────────── +class LSM6DSComponent final : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + // Configuration setters (called from Python codegen) + void set_accel_range(LSM6DSAccelRange r) { this->accel_range_ = r; } + void set_accel_odr(LSM6DSAccelODR o) { this->accel_odr_ = o; } + void set_gyro_range(LSM6DSGyroRange r) { this->gyro_range_ = r; } + void set_gyro_odr(LSM6DSGyroODR o) { this->gyro_odr_ = o; } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + const char *chip_name_{"Unknown"}; + bool update_data(motion::MotionData &data) override; + + LSM6DSAccelRange accel_range_{LSM6DS_ACCEL_RANGE_4G}; + LSM6DSAccelODR accel_odr_{LSM6DS_ACCEL_ODR_104}; + LSM6DSGyroRange gyro_range_{LSM6DS_GYRO_RANGE_2000}; + LSM6DSGyroODR gyro_odr_{LSM6DS_GYRO_ODR_208}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lsm6ds diff --git a/esphome/components/lsm6ds/motion.py b/esphome/components/lsm6ds/motion.py new file mode 100644 index 0000000000..064cd312a9 --- /dev/null +++ b/esphome/components/lsm6ds/motion.py @@ -0,0 +1,107 @@ +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 esphome.types import ConfigType + +from . import LSM6DSComponent, lsm6ds_ns + +# ── Dependency declarations ────────────────────────────────────────────────── +DEPENDENCIES = ["i2c"] +DOMAIN = "lsm6ds" + +# ── C++ namespace / class ──────────────────────────────────────────────────── +# ── Enum proxies ───────────────────────────────────────────────────────────── +LSM6DSAccelRange = lsm6ds_ns.enum("LSM6DSAccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_2G, + "4G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_4G, + "8G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_8G, + "16G": LSM6DSAccelRange.LSM6DS_ACCEL_RANGE_16G, +} + +LSM6DSAccelODR = lsm6ds_ns.enum("LSM6DSAccelODR") +ACCEL_ODR_OPTIONS = { + "OFF": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_OFF, + "12_5HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_12_5, + "26HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_26, + "52HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_52, + "104HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_104, + "208HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_208, + "416HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_416, + "833HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_833, + "1666HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_1666, + "3332HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_3332, + "6664HZ": LSM6DSAccelODR.LSM6DS_ACCEL_ODR_6664, +} + +LSM6DSGyroRange = lsm6ds_ns.enum("LSM6DSGyroRange") +GYRO_RANGE_OPTIONS = { + "125DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_125, + "250DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_250, + "500DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_500, + "1000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_1000, + "2000DPS": LSM6DSGyroRange.LSM6DS_GYRO_RANGE_2000, +} + +LSM6DSGyroODR = lsm6ds_ns.enum("LSM6DSGyroODR") +GYRO_ODR_OPTIONS = { + "OFF": LSM6DSGyroODR.LSM6DS_GYRO_ODR_OFF, + "12_5HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_12_5, + "26HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_26, + "52HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_52, + "104HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_104, + "208HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_208, + "416HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_416, + "833HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_833, + "1666HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_1666, + "3332HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_3332, + "6664HZ": LSM6DSGyroODR.LSM6DS_GYRO_ODR_6664, +} + +# ── CONFIG_SCHEMA ───────────────────────────────────────────────────────────── +# Extend the motion platform schema which provides: +# - accel_x/y/z sensor schemas +# - gyro_x/y/z sensor schemas +# - axis_mapping schema + validation +# - update_interval / polling +CONFIG_SCHEMA = ( + motion_schema(LSM6DSComponent, 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="104HZ"): cv.enum( + ACCEL_ODR_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="2000DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_ODR, default="208HZ"): cv.enum( + GYRO_ODR_OPTIONS, upper=True + ), + } + ) + .extend(i2c.i2c_device_schema(0x6A)) +) + + +# ── Code generation ────────────────────────────────────────────────────────── +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + + # Let the motion platform handle sensor wiring, axis mapping, and polling + await i2c.register_i2c_device(var, config) + + # Chip-specific 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/lsm6ds/sensor.py b/esphome/components/lsm6ds/sensor.py new file mode 100644 index 0000000000..c0c37f8527 --- /dev/null +++ b/esphome/components/lsm6ds/sensor.py @@ -0,0 +1,40 @@ +# 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 esphome.types import ConfigType + +from . import CONF_LSM6DS_ID, LSM6DSComponent + +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): CONF_TEMPERATURE, + cv.GenerateID(CONF_LSM6DS_ID): cv.use_id(LSM6DSComponent), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LSM6DS_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/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/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 37fceaf984..c3ac90ad11 100644 --- a/esphome/components/ltr390/sensor.py +++ b/esphome/components/ltr390/sensor.py @@ -13,6 +13,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_LUX, ) +from esphome.types import ConfigType CODEOWNERS = ["@sjtrny", "@latonita"] DEPENDENCIES = ["i2c"] @@ -117,7 +118,7 @@ TYPES = { } -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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..c2091a6336 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -15,7 +17,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, @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -87,17 +90,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -107,7 +110,7 @@ def validate_time_and_repeat_rate(config): return config -def validate_als_gain_and_integration_time(config): +def validate_als_gain_and_integration_time(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] if config[CONF_GAIN] == "1X" and integraton_time > 100: raise cv.Invalid( @@ -159,7 +162,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 +172,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 +182,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 +191,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, @@ -221,7 +224,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 893415f028..af09282e2d 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -1,3 +1,5 @@ +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import i2c, sensor @@ -23,6 +25,7 @@ from esphome.const import ( UNIT_LUX, UNIT_MILLISECOND, ) +from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] @@ -93,17 +96,17 @@ PS_GAINS = { } -def validate_integration_time(value): +def validate_integration_time(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(INTEGRATION_TIMES, int=True)(value) -def validate_repeat_rate(value): +def validate_repeat_rate(value: Any) -> Any: value = cv.positive_time_period_milliseconds(value).total_milliseconds return cv.enum(MEASUREMENT_REPEAT_RATES, int=True)(value) -def validate_time_and_repeat_rate(config): +def validate_time_and_repeat_rate(config: ConfigType) -> ConfigType: integraton_time = config[CONF_INTEGRATION_TIME] repeat_rate = config[CONF_REPEAT] if integraton_time > repeat_rate: @@ -211,7 +214,7 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 91b101cd25..2d2f1d6288 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -6,6 +6,7 @@ import re from esphome.automation import Trigger, build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import ( + BYTE_ORDER_BIG, CONF_BYTE_ORDER, CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, @@ -29,12 +30,10 @@ from esphome.components.image import ( from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( - CONF_AUTO_CLEAR_ENABLED, CONF_BUFFER_SIZE, CONF_ESPHOME, CONF_GROUP, CONF_ID, - CONF_LAMBDA, CONF_LOG_LEVEL, CONF_ON_IDLE, CONF_PAGES, @@ -47,13 +46,16 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.writer import clean_build from esphome.yaml_util import load_yaml 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, get_focused_widgets, get_lv_images_used, @@ -73,11 +75,16 @@ from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, + SET_STATE_SCHEMA, + STATE_SCHEMA, STYLE_REMAP, + STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, + apply_style_driven_defines, container_schema, - obj_schema, + container_schema_value, + theme_schema, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -110,6 +117,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA page_spec, ) +# These style schemas live in .schemas but are imported here so they land in +# this module's namespace, where script/build_language_schema.py registers them +# as *named* schemas and emits `extends` references — instead of inlining the +# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the +# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in +# this file; this tuple keeps the imports live (and self-documents why). +_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA) + # Widget registration happens via WidgetType.__init__ in individual widget files # The imports below trigger creation of the widget types # Action registration (lvgl.{widget}.update) happens automatically @@ -130,6 +145,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, ) @@ -170,7 +187,7 @@ def generate_lv_conf_h(): if clashes: LOGGER.warning( "Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s", - sorted(list(clashes)), + sorted(clashes), ) unused_defines = all_defines - lv_defines.keys() - defines_from_flags @@ -195,6 +212,18 @@ def multi_conf_validate(configs: list[dict]): raise cv.Invalid( f"'{item}' must have an explicit group set when using multiple LVGL instances" ) + # The hidden styles a `theme:` block creates are tracked in a single map shared + # by all LVGL instances (keyed only by widget type, not by instance), so a + # second instance's `theme:` would silently lose to whichever instance is + # processed first instead of doing what its config implies. + themed_configs = sum( + 1 for config in configs if config.get(df.CONF_THEME) is not None + ) + if themed_configs > 1: + raise cv.Invalid( + "'theme' may only be set on one LVGL instance when using multiple LVGL " + "instances -- combine both themes into a single instance's 'theme:' block" + ) base_config = configs[0] for config in configs[1:]: for item in ( @@ -210,61 +239,73 @@ def multi_conf_validate(configs: list[dict]): def final_validation(config_list): - if len(config_list) != 1: - multi_conf_validate(config_list) global_config = full_config.get() + # Resolve byte_order from display metadata before multi-config validation for config in config_list: + metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]] + if any(m.has_writer for m in metas): + raise cv.Invalid( + "Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL" + ) + if any(m.rotation != 0 for m in metas): + raise cv.Invalid( + "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" + ) + config[CONF_DRAW_ROUNDING] = max( + [m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]] + ) + display_byte_orders = { + m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED + } + if len(display_byte_orders) > 1: + raise cv.Invalid( + "All displays configured for an LVGL instance must use the same byte_order" + ) + if display_byte_orders: + display_order = next(iter(display_byte_orders)) + if CONF_BYTE_ORDER in config: + if config[CONF_BYTE_ORDER] != display_order: + raise cv.Invalid( + "LVGL byte order must match the display byte order", + [CONF_BYTE_ORDER], + ) + else: + config[CONF_BYTE_ORDER] = display_order + if CONF_BYTE_ORDER not in config: + config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG + if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages): raise cv.Invalid("At least one page must not be skipped") - for display_id in config[df.CONF_DISPLAYS]: - path = global_config.get_path_for_id(display_id)[:-1] - display = global_config.get_config_for_path(path) - if CONF_LAMBDA in display or CONF_PAGES in display: - raise cv.Invalid( - "Using lambda: or pages: in display config is not compatible with LVGL" - ) - # treating 0 as false is intended here. - if display.get(CONF_ROTATION): - raise cv.Invalid( - "use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead" - ) - if display.get(CONF_AUTO_CLEAR_ENABLED) is True: - raise cv.Invalid( - "Using auto_clear_enabled: true in display config not compatible with LVGL" - ) - if draw_rounding := display.get(CONF_DRAW_ROUNDING): - config[CONF_DRAW_ROUNDING] = max( - draw_rounding, config[CONF_DRAW_ROUNDING] - ) buffer_frac = config[CONF_BUFFER_SIZE] if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config: df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM") - for w in get_focused_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if ( - df.CONF_ADJUSTABLE in widget_conf - and not widget_conf[df.CONF_ADJUSTABLE] - ): - raise cv.Invalid( - "A non adjustable arc may not be focused", - path, - ) - for w in get_refreshed_widgets(): - path = global_config.get_path_for_id(w) - widget_conf = global_config.get_config_for_path(path[:-1]) - if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): - raise cv.Invalid( - f"Widget '{w}' does not have any dynamic properties to refresh", - ) - # Do per-widget type final validation for update actions - for widget_type, update_configs in df.get_updated_widgets().items(): - for conf in update_configs: - for id_conf in conf.get(CONF_ID, ()): - name = id_conf[CONF_ID] - path = global_config.get_path_for_id(name) - widget_conf = global_config.get_config_for_path(path[:-1]) - widget_type.final_validate(name, conf, widget_conf, path[1:]) + + if len(config_list) != 1: + multi_conf_validate(config_list) + + for w in get_focused_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]: + raise cv.Invalid( + "A non adjustable arc may not be focused", + path, + ) + for w in get_refreshed_widgets(): + path = global_config.get_path_for_id(w) + widget_conf = global_config.get_config_for_path(path[:-1]) + if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()): + raise cv.Invalid( + f"Widget '{w}' does not have any dynamic properties to refresh", + ) + # Do per-widget type final validation for update actions + for widget_type, update_configs in df.get_updated_widgets().items(): + for conf in update_configs: + for id_conf in conf.get(CONF_ID, ()): + name = id_conf[CONF_ID] + path = global_config.get_path_for_id(name) + widget_conf = global_config.get_config_for_path(path[:-1]) + widget_type.final_validate(name, conf, widget_conf, path[1:]) async def to_code(configs): @@ -363,8 +404,7 @@ async def to_code(configs): # options will have CONF_ROTATION true if rotation is changed in an automation. if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True: if all( - get_display_metadata(str(disp)).has_hardware_rotation - for disp in displays + get_display_metadata(disp).has_hardware_rotation for disp in displays ): rotation_type = RotationType.ROTATION_HARDWARE df.LOGGER.info("LVGL will use hardware rotation via display driver") @@ -387,6 +427,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) @@ -404,14 +448,25 @@ 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(): + # Local import: lv_list imports meter, which imports obj_spec/set_obj_properties + # from this module's own namespace - a top-level import here would be circular. + from .widgets.lv_list import finish_list_triggers + + # Must run before generate_triggers(): that's what actually processes other + # widgets' on_click etc. automations, which can include lvgl.list.add/remove/ + # clear actions that fire a list's on_add/on_remove triggers - those need to + # already exist by then, not still be pending. + await finish_list_triggers() await generate_triggers() 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, ()): @@ -433,32 +488,16 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() + apply_style_driven_defines(styles_used) for use in df.get_lv_uses(): df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") - if { - "transform_rotation", - "transform_scale", - "transform_scale_x", - "transform_scale_y", - } & styles_used: - df.add_define("LV_COLOR_SCREEN_TRANSP", "1") - if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): df.add_define("LV_THEME_DEFAULT_DARK", "1") # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending lv_image_formats = {"RGB565", "ARGB8888"} - if { - "drop_shadow_color", - "drop_shadow_offset_x", - "drop_shadow_offset_y", - "drop_shadow_opa", - "drop_shadow_quality", - "drop_shadow_radius", - } & styles_used: - lv_image_formats.add("A8") for image_id in get_lv_images_used(): await cg.get_variable(image_id) @@ -513,108 +552,111 @@ def add_hello_world(config): return config -def _theme_schema(value): - return cv.Schema( - { - cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, - **{ - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() - }, - } - )(value) - - FINAL_VALIDATE_SCHEMA = final_validation -LVGL_SCHEMA = cv.All( - container_schema( - obj_spec, - cv.polling_component_schema("1s") - .extend( - { - **{ - cv.Optional(event): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) - ), - } - ) - for event in df.LV_SCREEN_EVENT_TRIGGERS - + df.LV_DISPLAY_EVENT_TRIGGERS - }, - cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), - cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), - cv.GenerateID(df.CONF_DISPLAYS): display_schema, - cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), - cv.Optional( - df.CONF_DEFAULT_FONT, default="montserrat_14" - ): lvalid.lv_font, - cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, - cv.Optional( - df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False - ): cv.boolean, - cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, - cv.Optional(CONF_ROTATION): validate_rotation, - cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( - *df.LV_LOG_LEVELS, upper=True - ), - cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of( - "big_endian", "little_endian", lower=True - ), - cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( - FULL_STYLE_SCHEMA - ) - ), - cv.Optional(CONF_ON_IDLE): validate_automation( +# The options accepted at the top level of an `lvgl:` block, on top of the base +# object schema that `container_schema(obj_spec, ...)` supplies. Held in a +# module-level name (rather than inline) so the schema-extractor wrapper on +# CONFIG_SCHEMA below can hand the language-schema dumper the same composed +# schema the runtime validates against. +LVGL_TOP_LEVEL_SCHEMA = ( + cv.polling_component_schema("1s") + .extend( + { + **{ + cv.Optional(event): validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), - cv.Required(CONF_TIMEOUT): cv.templatable( - cv.positive_time_period_milliseconds + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + Trigger.template(lv_obj_t_ptr, lv_event_t_ptr) ), } - ), - cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), - **{ - cv.Optional(x): validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), - }, - single=True, - ) - for x in SIMPLE_TRIGGERS - }, - cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), - cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool, - cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec), - cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec), - cv.Optional( - df.CONF_TRANSPARENCY_KEY, default=0x000400 - ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): _theme_schema, - cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, - cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, - cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, - cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, - cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), - cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, - } - ) - .extend(DISP_BG_SCHEMA), - ), + ) + for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS + }, + cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), + cv.GenerateID(df.CONF_DISPLAYS): display_schema, + cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), + cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font, + cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean, + cv.Optional(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, + cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( + *df.LV_LOG_LEVELS, upper=True + ), + cv.Optional(CONF_BYTE_ORDER): cv.one_of( + "big_endian", "little_endian", lower=True + ), + cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list( + cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend( + FULL_STYLE_SCHEMA + ) + ), + cv.Optional(CONF_ON_IDLE): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger), + cv.Required(CONF_TIMEOUT): cv.templatable( + cv.positive_time_period_milliseconds + ), + } + ), + cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)), + **{ + cv.Optional(x): validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger), + }, + single=True, + ) + for x in SIMPLE_TRIGGERS + }, + cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA), + cv.Optional(df.CONF_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), + cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color, + cv.Optional(df.CONF_THEME): theme_schema, + cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, + cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, + cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, + cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG, + cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t), + cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean, + cv.Optional(df.CONF_PAUSED, default=False): cv.boolean, + } + ) + .extend(DISP_BG_SCHEMA) +) + + +LVGL_SCHEMA = cv.All( + container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, ) +@schema_extractor("schema") def lvgl_config_schema(config): """ Can't use cv.ensure_list here because it converts an empty config to an empty list, rather than a default config. """ + if config is SCHEMA_EXTRACT: + # CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema + # closure, so the language-schema dumper can't see the top-level `lvgl:` + # fields (it would emit an empty schema). Hand it the same composed + # obj + top-level schema the runtime validates against, plus the + # `widgets:` key (added per-value by append_layout_schema at runtime, so + # otherwise invisible to the dumper). Validation of real configs (the + # branches below) is unchanged. + return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend( + {cv.Optional(df.CONF_WIDGETS): any_widget_schema()} + ) if not config or isinstance(config, dict): return [LVGL_SCHEMA(config)] return cv.Schema([LVGL_SCHEMA])(config) diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h new file mode 100644 index 0000000000..26bb433f87 --- /dev/null +++ b/esphome/components/lvgl/animation.h @@ -0,0 +1,207 @@ +#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: + // moving_length_ is the fraction of progress spent moving in each direction, in (0, 0.5]. + // Callers must pass pause in [0, 1) -- pause == 1.0 would make moving_length_ zero and divide by zero below. + LvAnimationTimingRoundTrip(float pause) : moving_length_((1.0f - pause) / 2.0f) {} + float map_progress(float value) override { + if (value < this->moving_length_) { + return value / this->moving_length_; + } + if (value > 1.0f - this->moving_length_) { + return (1.0f - value) / this->moving_length_; + } + // pause in the middle + return 1.0f; + } + + protected: + float moving_length_{}; +}; + +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..95d45de5ea --- /dev/null +++ b/esphome/components/lvgl/animation.py @@ -0,0 +1,306 @@ +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" +CONF_PAUSE = "pause" + + +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", + { + cv.Optional(CONF_PAUSE, default=0.0): cv.All( + cv.percentage, + cv.float_range( + min=0.0, max=1.0, min_included=True, max_included=False + ), + ) + }, + ), + timing_class( + "ease_in_out", + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_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..a62f466413 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -4,9 +4,15 @@ 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.const import ( + CONF_ACTION, + CONF_GROUP, + CONF_ID, + CONF_POSITION, + CONF_ROTATION, + CONF_TIMEOUT, +) from esphome.core import Lambda from esphome.cpp_generator import TemplateArguments, get_variable from esphome.cpp_types import nullptr @@ -16,6 +22,7 @@ from .defines import ( CONF_BOTTOM_LAYER, CONF_EDITING, CONF_FREEZE, + CONF_LAYOUT, CONF_LVGL_ID, CONF_MAIN, CONF_OBJ, @@ -28,13 +35,16 @@ from .defines import ( get_focused_widgets, get_options, get_refreshed_widgets, + literal, ) -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, LambdaContext, LocalVariable, + LvConditional, LvglComponent, ReturnStatement, add_line_marks, @@ -199,7 +209,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 +228,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 +265,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 +280,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 +291,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 @@ -361,6 +385,48 @@ async def obj_show_to_code(config, action_id, template_arg, args): return await action_to_code(widgets, do_show, action_id, template_arg, args) +SET_Z_INDEX_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.ensure_list( + cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(lv_obj_t)}, + key=CONF_ID, + ) + ), + cv.Required(CONF_POSITION): cv.Any( + cv.one_of("TOP", "BOTTOM", "UP", "DOWN", upper=True), cv.int_ + ), + } +) + + +@automation.register_action( + "lvgl.widget.set_z_index", ObjUpdateAction, SET_Z_INDEX_SCHEMA, synchronous=True +) +async def obj_set_z_index_to_code(config, action_id, template_arg, args): + position = config[CONF_POSITION] + + async def do_set_z_index(widget: Widget): + if position == "TOP": + lv_obj.move_foreground(widget.obj) + elif position == "BOTTOM": + lv_obj.move_background(widget.obj) + elif position == "UP": + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} + 1") + ) + elif position == "DOWN": + with LvConditional(literal(f"{lv_expr.obj_get_index(widget.obj)} > 0")): + lv_obj.move_to_index( + widget.obj, literal(f"{lv_expr.obj_get_index(widget.obj)} - 1") + ) + else: + lv_obj.move_to_index(widget.obj, position) + + widgets = [widget.outer or widget for widget in await get_widgets(config[CONF_ID])] + return await action_to_code(widgets, do_set_z_index, action_id, template_arg, args) + + def focused_id(value): value = cv.use_id(lv_pseudo_button_t)(value) get_focused_widgets().add(value) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 15a24f1ad2..81a4d2b4ab 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -33,6 +33,7 @@ KEY_NAMED_STYLES = "named_styles" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" +KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" KEY_THEME_WIDGET_MAP = "theme_widget_map" KEY_UPDATED_WIDGETS = "updated_widgets" KEY_WIDGET_MAP = "widget_map" @@ -118,6 +119,14 @@ def get_theme_widget_map() -> dict[str, Any]: return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: + # Values are dicts used as ordered sets (insertion order is deterministic, + # unlike a plain `set` of strings/tuples, whose iteration order depends on + # per-process string hash randomization) so codegen output doesn't churn + # between builds of the same config. + return _get_data(KEY_THEME_UPDATE_REQUESTS, {}) + + def get_styles_used() -> set[str]: return _get_data(KEY_STYLES_USED, set()) @@ -214,11 +223,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 +240,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 +251,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): @@ -335,7 +354,7 @@ TYPE_NONE = "none" DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP") -LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ +LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [ "dejavu_16_persian_hebrew", "simsun_16_cjk", "unscii_8", @@ -566,6 +585,21 @@ FLEX_FLOWS = LvConstant( "COLUMN_WRAP_REVERSE", ) +TRANSFORM_STYLE_PROPS = frozenset( + {"transform_rotation", "transform_scale", "transform_scale_x", "transform_scale_y"} +) + +DROP_SHADOW_STYLE_PROPS = frozenset( + { + "drop_shadow_color", + "drop_shadow_offset_x", + "drop_shadow_offset_y", + "drop_shadow_opa", + "drop_shadow_quality", + "drop_shadow_radius", + } +) + OBJ_FLAGS = ( "hidden", "clickable", @@ -729,6 +763,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 +777,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 +797,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/helpers.py b/esphome/components/lvgl/helpers.py index baa618d472..3da8643308 100644 --- a/esphome/components/lvgl/helpers.py +++ b/esphome/components/lvgl/helpers.py @@ -6,30 +6,28 @@ from esphome.const import CONF_ARGS, CONF_FORMAT CONF_IF_NAN = "if_nan" -# noqa f_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - f # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + f # type ) """, flags=re.VERBOSE, ) -# noqa c_regex = re.compile( r""" - ( # start of capture group 1 - % # literal "%" - [-+0 #]{0,5} # optional flags - (?:\d+|\*)? # width - (?:\.(?:\d+|\*))? # precision - (?:h|l|ll|w|I|I32|I64)? # size - [cCdiouxXeEfgGaAnpsSZ] # type + ( # start of capture group 1 + % # literal "%" + [-+0 #]{0,5} # optional flags + (?:\d+|\*)? # width + (?:\.(?:\d+|\*))? # precision + (?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size + [cCdiouxXeEfgGaAnpsSZ] # type ) """, flags=re.VERBOSE, 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 a1b75182eb..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})") - assert False + 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/lvcode.py b/esphome/components/lvgl/lvcode.py index de00593773..850b63a26f 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -242,7 +242,7 @@ class LocalVariable(MockObj): self.base.type, self.modifier, self.base.id ) ) - return MockObj(self.base) + return MockObj(self.base, "->" if self.modifier == "*" else ".") def __exit__(self, *args): CodeContext.end_block() @@ -283,7 +283,15 @@ class MockLv: class LvConditional: def __init__(self, condition): - self.condition = condition + # Condition is embedded directly into a raw `if (...)` statement below, rather than + # going through the argument-list machinery (ExpressionList) that would otherwise + # convert a native Python value (e.g. a plain bool) to a proper Expression. + if isinstance(condition, str): + raise ValueError( + "LvConditional condition must not be a raw str; wrap it in literal() " + "if a string literal condition is really intended" + ) + self.condition = cg.safe_exp(condition) if condition is not None else None def __enter__(self): if self.condition is not None: @@ -303,6 +311,35 @@ class LvConditional: CodeContext.code_context.indent() +class LvCountdown: + """ + Emits a C++ `for` loop that counts an int variable down from `count - 1` to `0` inclusive. + Used to iterate over a widget's children in reverse, e.g. to fire a trigger once per child + before they're all removed. + """ + + def __init__(self, var_name: str, count): + self.var_name = var_name + self.count = count + + def __enter__(self): + # Cast explicitly rather than relying on `count`'s (typically unsigned) type to wrap + # and then narrow back to a negative int when count is 0 -- true in practice on every + # toolchain ESPHome targets, but not worth leaning on. + CodeContext.append( + RawStatement( + f"for (int {self.var_name} = (int) ({self.count}) - 1; {self.var_name} >= 0; " + f"{self.var_name}--) {{" + ) + ) + CodeContext.code_context.indent() + return literal(self.var_name) + + def __exit__(self, *args): + CodeContext.code_context.detent() + CodeContext.append(RawStatement("}")) + + class ReturnStatement(ExpressionStatement): def __str__(self): return f"return {self.expression};" diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 678ed9dbbf..a10fdb0582 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: @@ -195,21 +208,21 @@ void LvglComponent::esphome_lvgl_init() { lv_update_event = static_cast(lv_event_register_id()); } -void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) { - lv_obj_add_event_cb(obj, callback, event, nullptr); +void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event, void *user_data) { + lv_obj_add_event_cb(obj, callback, event, user_data); } void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, - lv_event_code_t event2) { - add_event_cb(obj, callback, event1); - add_event_cb(obj, callback, event2); + lv_event_code_t event2, void *user_data) { + add_event_cb(obj, callback, event1, user_data); + add_event_cb(obj, callback, event2, user_data); } void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, - lv_event_code_t event2, lv_event_code_t event3) { - add_event_cb(obj, callback, event1); - add_event_cb(obj, callback, event2); - add_event_cb(obj, callback, event3); + lv_event_code_t event2, lv_event_code_t event3, void *user_data) { + add_event_cb(obj, callback, event1, user_data); + add_event_cb(obj, callback, event2, user_data); + add_event_cb(obj, callback, event3, user_data); } void LvglComponent::add_page(LvPageType *page) { @@ -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, @@ -428,7 +444,7 @@ LVTouchListener::LVTouchListener(uint16_t long_press_time, uint16_t long_press_r lv_indev_set_type(this->drv_, LV_INDEV_TYPE_POINTER); lv_indev_set_disp(this->drv_, parent->get_disp()); lv_indev_set_long_press_time(this->drv_, long_press_time); - // long press repeat time TBD + lv_indev_set_long_press_repeat_time(this->drv_, long_press_repeat_time); lv_indev_set_user_data(this->drv_, this); lv_indev_set_read_cb(this->drv_, [](lv_indev_t *d, lv_indev_data_t *data) { auto *l = static_cast(lv_indev_get_user_data(d)); @@ -509,6 +525,52 @@ void IndicatorLine::update_length_() { } #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return row; +} + +uint32_t lv_table_get_selected_column(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return column; +} + +void LvTableType::set_obj(lv_obj_t *lv_obj) { + LvCompound::set_obj(lv_obj); + lv_obj_add_event_cb( + lv_obj, + [](lv_event_t *e) { + auto *table = static_cast(lv_event_get_user_data(e)); + table->update_column_widths_(); + }, + LV_EVENT_SIZE_CHANGED, this); +} + +void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) { + for (auto &i : this->column_pct_) { + if (i.col == col) { + i.pct = pct; + this->update_column_widths_(); + return; + } + } + this->column_pct_.push_back({col, pct}); + this->update_column_widths_(); +} + +void LvTableType::update_column_widths_() { + auto content_width = lv_obj_get_content_width(this->obj); + for (const auto &col : this->column_pct_) { + lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100); + } +} +#endif // USE_LVGL_TABLE + #ifdef USE_LVGL_KEY_LISTENER LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) { this->drv_ = lv_indev_create(); @@ -535,21 +597,21 @@ std::string LvSelectable::get_selected_text() { return this->options_[selected]; } -static std::string join_string(std::vector options) { +static std::string join_string(const FixedVector &options) { return std::accumulate( options.begin(), options.end(), std::string(), - [](const std::string &a, const std::string &b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; }); + [](const std::string &a, const char *b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; }); } void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t anim) { - auto index = std::find(this->options_.begin(), this->options_.end(), text); + auto *index = std::find(this->options_.begin(), this->options_.end(), text); if (index != this->options_.end()) { this->set_selected_index(index - this->options_.begin(), anim); lv_obj_send_event(this->obj, lv_update_event, nullptr); } } -void LvSelectable::set_options(std::vector options) { +void LvSelectable::set_options(FixedVector options) { auto index = this->get_selected_index(); if (index >= options.size()) index = options.size() - 1; @@ -572,7 +634,7 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) { auto key_idx = lv_buttonmatrix_get_selected_button(self->obj); if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE) return; - if (self->key_map_.count(key_idx) != 0) { + if (self->key_map_.contains(key_idx)) { self->send_key_(self->key_map_[key_idx]); return; } @@ -620,20 +682,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 +778,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 +828,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 +851,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 +867,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 +879,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 @@ -921,6 +1009,25 @@ lv_obj_t *lv_container_create(lv_obj_t *parent) { lv_obj_class_init_obj(obj); return obj; } + +#ifdef USE_LVGL_LIST +int lv_list_get_row_index(lv_obj_t *list, lv_obj_t *child) { + for (lv_obj_t *obj = child; obj != nullptr; obj = lv_obj_get_parent(obj)) { + if (lv_obj_get_parent(obj) == list) + return lv_obj_get_index(obj); + } + ESP_LOGW(TAG, "lvgl.list: entry is not inside the list it was added to"); + return -1; +} + +lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index) { + lv_obj_t *child = index < 0 ? nullptr : lv_obj_get_child(list, index); + if (child == nullptr) { + ESP_LOGW(TAG, "lvgl.list.remove: index %d is out of range, ignoring", index); + } + return child; +} +#endif // USE_LVGL_LIST } // namespace esphome::lvgl lv_result_t lv_mem_test_core() { return LV_RESULT_OK; } @@ -929,7 +1036,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 218f9a60ab..8b7397c4cd 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent); void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start, lv_color_t color_end, int width, bool local); #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj); +uint32_t lv_table_get_selected_column(lv_obj_t *obj); +#endif #if LV_COLOR_DEPTH == 16 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565; #elif LV_COLOR_DEPTH == 32 @@ -74,11 +78,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { lv_style_set_text_font(style, font->get_lv_font()); } #endif -#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) -#if LV_USE_IMAGE + +#ifdef USE_IMAGE +#ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda. inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); } -#endif // LV_USE_IMAGE inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { ::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); @@ -93,7 +97,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) { inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) { ::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc()); } -#endif // USE_LVGL_IMAGE +#endif + #ifdef USE_LVGL_ANIMIMG inline void lv_animimg_set_src(lv_obj_t *img, std::vector images) { auto *dsc = static_cast *>(lv_obj_get_user_data(img)); @@ -109,11 +114,24 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size()); } #endif // USE_LVGL_ANIMIMG +#endif // USE_IMAGE #ifdef USE_LVGL_METER int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value); #endif +#ifdef USE_LVGL_LIST +// Returns the index, within `list`, of the entry that contains `child`: `child` itself if it's a +// direct child of `list`, or the ancestor of `child` that is, when `child` is nested inside a +// widget hierarchy added via `lvgl.list.add`. Returns -1 if `child` isn't inside `list` at all. +int lv_list_get_row_index(lv_obj_t *list, lv_obj_t *child); + +// Returns the entry at `index` within `list`, or nullptr (logging why) if `index` is out of +// range -- shared by every `lvgl.list.remove` call site, since a templatable index can go out of +// range at runtime in ways config validation can't catch (e.g. driven by a sensor value). +lv_obj_t *lv_list_get_row_for_remove(lv_obj_t *list, int index); +#endif + #ifdef USE_LVGL_GRADIENT /** * @@ -133,6 +151,12 @@ class LvCompound { lv_obj_t *obj{}; }; +// Frees a heap-allocated LvCompound wrapper on LV_EVENT_DELETE, since lv_obj_del() only knows how to destroy LVGL's own +// object tree, not a separate C++ object paired with one of its nodes. +template void delete_lv_compound_on_delete(lv_event_t *e) { + delete static_cast(lv_event_get_user_data(e)); +} + class LvglComponent; class LvPageType : public Parented { @@ -153,7 +177,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) {} @@ -165,7 +189,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)) {} @@ -183,7 +207,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: @@ -212,9 +242,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_) { @@ -228,10 +263,11 @@ class LvglComponent : public PollingComponent { static void esphome_lvgl_init(); // Convenience overloads for adding a callback for one or more events - static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event); - static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2); + static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event, void *user_data = nullptr); static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, - lv_event_code_t event3); + void *user_data = nullptr); + static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, + lv_event_code_t event3, void *user_data = nullptr); // change the state of a widget and fire an event if changed (only needed for CHECKED) @@ -284,7 +320,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; @@ -293,10 +333,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); @@ -314,6 +360,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_{}; @@ -329,6 +383,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_; @@ -337,7 +394,7 @@ class LvglComponent : public PollingComponent { #endif }; -class IdleTrigger : public Trigger<> { +class IdleTrigger final : public Trigger<> { public: explicit IdleTrigger(LvglComponent *parent, TemplatableFn timeout); @@ -346,7 +403,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)) {} @@ -355,7 +412,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_); } @@ -365,7 +422,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; @@ -401,7 +458,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); @@ -458,6 +515,27 @@ class LvLineType : public LvCompound { FixedVector points_{}; }; #endif +#ifdef USE_LVGL_TABLE +// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel +// count, so percentage column widths must be recomputed by hand whenever the table's own +// content width changes. +class LvTableType : public LvCompound { + public: + void set_obj(lv_obj_t *lv_obj) override; + // count is the number of percentage-width columns, known at code-generation time. + void init_column_pct(size_t count) { this->column_pct_.init(count); } + void add_column_width_pct(uint32_t col, uint8_t pct); + + protected: + void update_column_widths_(); + + struct ColumnPct { + uint32_t col; + uint8_t pct; + }; + FixedVector column_pct_{}; +}; +#endif // USE_LVGL_TABLE #if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER) class LvSelectable : public LvCompound { public: @@ -465,12 +543,12 @@ class LvSelectable : public LvCompound { virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0; void set_selected_text(const std::string &text, lv_anim_enable_t anim); std::string get_selected_text(); - const std::vector &get_options() { return this->options_; } - void set_options(std::vector options); + const FixedVector &get_options() { return this->options_; } + void set_options(FixedVector options); protected: virtual void set_option_string(const char *options) = 0; - std::vector options_{}; + FixedVector options_{}; }; #ifdef USE_LVGL_DROPDOWN 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 553e0f7398..bbc977dca5 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,11 +1,15 @@ from collections.abc import Callable +import functools +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 ( CONF_ARGS, + CONF_DEFAULT, CONF_FORMAT, CONF_GROUP, CONF_ID, @@ -16,15 +20,22 @@ from esphome.const import ( CONF_TEXT, CONF_TIME, CONF_TRIGGER_ID, + CONF_VALUE, CONF_X, CONF_Y, ) from esphome.core import TimePeriod from esphome.core.config import StartupTrigger +from esphome.schema_extractors import ( + SCHEMA_EXTRACT, + EnableSchemaExtraction, + schema_extractor, +) 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, @@ -46,6 +57,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 @@ -59,7 +71,7 @@ from .types import ( lv_pseudo_button_t, lv_style_t, ) -from .widgets import WidgetType +from .widgets import WidgetType, collect_parts # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} @@ -83,6 +95,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): """ @@ -94,6 +120,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) @@ -377,18 +405,63 @@ TRIGGER_EVENT_MAP = { } -def part_schema(parts): +def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]: + """ + Return the raw mapping used by part_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls (each .extend() recompiles the + whole mapping, turning the build into O(N^2)). + + Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the + nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and + required=False and must not register any add_extra/prepend_extra + validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps + only the mapping; non-default extra/required and any _extra_schemas would + be silently dropped. + """ + return { + **STATE_SCHEMA.schema, + **FLAG_SCHEMA.schema, + **{cv.Optional(part): STATE_SCHEMA for part in parts}, + } + + +def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema: """ Generate a schema for the various parts (e.g. main:, indicator:) of a widget type :param parts: The parts to include :return: The schema """ - return STATE_SCHEMA.extend(FLAG_SCHEMA).extend( - {cv.Optional(part): STATE_SCHEMA for part in parts} - ) + return cv.Schema(part_dict(parts)) -def automation_schema(typ: LvType): +def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]: + """Return a validator that defers building the validate_automation schema. + + validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which + voluptuous compiles eagerly. automation_schema() builds ~60 of these per + widget type, and the vast majority of slots are never invoked by a given + user config. Deferring the build to first use removes that work from + schema-construction time. + + When EnableSchemaExtraction is set (build_language_schema.py), fall back + to eager construction so the @schema_extractor("automation") decoration + inside validate_automation is registered. + """ + if EnableSchemaExtraction: + return validate_automation(extra_schema) + + cached: Callable[[Any], Any] | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = validate_automation(extra_schema) + return cached(value) + + return validator + + +def automation_schema(typ: LvType) -> dict[Any, Any]: events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS if typ.has_on_value: events = events + (CONF_ON_VALUE, CONF_ON_UPDATE) @@ -403,7 +476,7 @@ def automation_schema(typ: LvType): return { **{ - cv.Optional(event): validate_automation( + cv.Optional(event): _lazy_validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( Trigger.template(*get_trigger_args(event)) @@ -412,7 +485,7 @@ def automation_schema(typ: LvType): ) for event in events }, - cv.Optional(CONF_ON_BOOT): validate_automation( + cv.Optional(CONF_ON_BOOT): _lazy_validate_automation( {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)} ), } @@ -453,6 +526,7 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): ) ), cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(df.CONF_LAYOUT): layout_validator, } ) @@ -461,25 +535,154 @@ def base_update_schema(widget_type: WidgetType | LvType, parts): return schema -def obj_schema(widget_type: WidgetType): +# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema(). +# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on +# every call, so without this cache _theme_schema pays that cost per widget +# per validation. Callers must treat the returned dict as immutable. The +# _theme_schema caller spreads it into a fresh dict, which is safe; the +# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores +# the mapping by reference but never mutates it (.extend() copies first), so +# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema +# would corrupt this cache. +_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {} + + +def obj_dict(widget_type: WidgetType) -> dict[Any, Any]: + """ + Return the raw mapping used by obj_schema, so callers can merge it into a + larger dict and avoid chained .extend() calls. + + Inherits the same source-schema invariant documented on part_dict: any + schema spread into this mapping must use the default extra=PREVENT_EXTRA + and required=False and must carry no add_extra/prepend_extra validators. + + The returned mapping is cached and must be treated as immutable by callers. + """ + cached = _OBJ_DICT_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + built = { + **part_dict(widget_type.parts), + **ALIGN_TO_SCHEMA, + **automation_schema(widget_type.w_type), + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), + } + _OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built) + return built + + +# Widget types are module-level singletons populated at import time, so we +# can cache compiled obj_schemas by widget_type identity for the lifetime of +# the process. The strong reference in the value keeps the key (an id() +# target) from being recycled. +_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {} + + +def obj_schema(widget_type: WidgetType) -> cv.Schema: """ Create a schema for a widget type itself i.e. no allowance for children :param widget_type: :return: """ - return ( - part_schema(widget_type.parts) - .extend(ALIGN_TO_SCHEMA) - .extend(automation_schema(widget_type.w_type)) - .extend( - { - cv.Optional(CONF_STATE): SET_STATE_SCHEMA, - cv.Optional(CONF_GROUP): cv.use_id(lv_group_t), - } - ) + cached = _OBJ_SCHEMA_CACHE.get(id(widget_type)) + if cached is not None and cached[0] is widget_type: + return cached[1] + schema = cv.Schema(obj_dict(widget_type)) + _OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema) + return schema + + +@functools.cache +def _build_theme_schema( + widget_types: tuple[tuple[str, WidgetType], ...], + include_dark_mode: bool = True, +) -> cv.Schema: + # The theme schema is value-independent: it depends only on the set of + # registered widget types. Key the cache on a snapshot of WIDGET_TYPES so + # that an external component registering a new widget after the first + # validation (legal per any_widget_schema's lazy-evaluation contract) + # produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache + # self-heals instead of stale-rejecting valid themes. See obj_dict() above + # for why chained .extend() is avoided here. + return cv.Schema( + { + **( + {cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean} + if include_dark_mode + else {} + ), + **{ + cv.Optional(name): cv.Schema( + {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} + ) + for name, w in widget_types + }, + } ) +def _reject_theme_styles_key(validated: dict) -> dict: + """ + `styles:` (a list of already-declared named styles) is not allowed inside + `theme:` -- the hidden style objects theme: creates only carry direct + style properties, so a `styles:` reference there would be silently + dropped by `style_set`, which only walks `ALL_STYLES`. + """ + for w_name, style in validated.items(): + if w_name not in WIDGET_TYPES: + continue + for part, states in collect_parts(style).items(): + for state, props in states.items(): + if df.CONF_STYLES not in props: + continue + path = [w_name] + if part != df.CONF_MAIN: + path.append(part) + if state != CONF_DEFAULT: + path.append(state) + path.append(df.CONF_STYLES) + raise cv.Invalid( + "'styles:' is not allowed in LVGL theme styles. " + "Set style properties directly instead.", + path, + ) + return validated + + +def theme_schema(value: dict) -> dict: + return _reject_theme_styles_key( + _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) + ) + + +def theme_update_schema(value: dict) -> dict: + """ + Schema for `lvgl.theme.update`: same shape as `theme:` minus `dark_mode`. + As a validation side effect, records which (widget type, part, state) + combos are targeted so `theme_to_code` can make sure a hidden style + exists for each -- even ones never mentioned under `theme:` -- and gets + it attached to widgets at the same point real theme styles are. + """ + validated = _reject_theme_styles_key( + _build_theme_schema(tuple(WIDGET_TYPES.items()), include_dark_mode=False)(value) + ) + for w_name, style in validated.items(): + for part, states in collect_parts(style).items(): + for state, props in states.items(): + # collect_parts() unconditionally seeds a main/default entry + # even when nothing was set for it (e.g. `{pressed: {...}}` + # alone) -- skip combos with no properties so a request for + # one state doesn't also create an unused, empty main/default + # style that gets attached to every widget of this type. + if not props: + continue + df.get_theme_update_requests().setdefault(w_name, {})[(part, state)] = ( + None + ) + return validated + + ALIGN_TO_SCHEMA = { cv.Optional(df.CONF_ALIGN_TO): cv.Schema( { @@ -523,6 +726,26 @@ ALL_STYLES = { } +def apply_style_driven_defines(props: set[str]) -> None: + """Given a set of style-property names in use, registers everything their use + drives: add_lv_use(image) if any of them is image-typed (per BASE_PROPS), and + the LV_COLOR_SCREEN_TRANSP / LV_DRAW_SW_SUPPORT_A8 defines. Shared between + __init__.py (driven by df.get_styles_used(), for statically-declared widgets) + and lv_list.py's _register_dynamic_widget_style_uses (driven by scanning a + dynamically-added widget's own config), so a future style-driven define added + to one can't be missed in the other. + """ + # Local import: avoids a module-load-time cycle (widgets.img -> ... -> schemas). + from .widgets.img import CONF_IMAGE + + if any(BASE_PROPS.get(prop) is lvalid.lv_image for prop in props): + df.add_lv_use(CONF_IMAGE) + if df.TRANSFORM_STYLE_PROPS & props: + df.add_define("LV_COLOR_SCREEN_TRANSP", "1") + if df.DROP_SHADOW_STYLE_PROPS & props: + df.add_define("LV_DRAW_SW_SUPPORT_A8", "1") + + def strip_defaults(schema: cv.Schema): """ Take a schema and remove any default values, also convert Required to Optional. @@ -534,7 +757,35 @@ def strip_defaults(schema: cv.Schema): return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()}) -def container_schema(widget_type: WidgetType, extras=None): +# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both +# alive so id() can't be recycled. +_CONTAINER_SCHEMA_CACHE: dict[ + tuple[int, int], tuple[Any, Any, Callable[[Any], Any]] +] = {} + + +def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema: + """ + Build the static schema that :func:`container_schema` validates against, i.e. + everything except the value-dependent ``append_layout_schema`` applied at + validation time. + + Factored out and exposed so the language-schema dumper can extract a + representative schema for a widget — and for the top-level ``lvgl:`` block, + whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the + :func:`container_schema` validator closure. + """ + schema = obj_schema(widget_type).extend( + {cv.GenerateID(): cv.declare_id(widget_type.w_type)} + ) + if extras: + schema = schema.extend(extras) + return schema.extend(widget_type.schema) + + +def container_schema( + widget_type: WidgetType, extras: Any = None +) -> Callable[[Any], Any]: """ Create a schema for a container widget of a given type. All obj properties are available, plus the extras passed in, plus any defined for the specific widget being specified. @@ -542,19 +793,26 @@ def container_schema(widget_type: WidgetType, extras=None): :param extras: Additional options to be made available, e.g. layout properties for children :return: The schema for this type of widget. """ - schema = obj_schema(widget_type).extend( - {cv.GenerateID(): cv.declare_id(widget_type.w_type)} - ) - if extras: - schema = schema.extend(extras) - # Delayed evaluation for recursion + cache_key = (id(widget_type), id(extras)) + cached = _CONTAINER_SCHEMA_CACHE.get(cache_key) + if cached is not None: + cached_widget_type, cached_extras, cached_validator = cached + if cached_widget_type is widget_type and cached_extras is extras: + return cached_validator - schema = schema.extend(widget_type.schema) + cached_schema: cv.Schema | None = None - def validator(value): + def get_schema() -> cv.Schema: + nonlocal cached_schema + if cached_schema is None: + cached_schema = container_schema_value(widget_type, extras) + return cached_schema + + def validator(value: Any) -> Any: value = value or {} - return append_layout_schema(schema, value)(value) + return append_layout_schema(get_schema(), value)(value) + _CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator) return validator @@ -571,7 +829,23 @@ def any_widget_schema(extras=None): :return: A validator for the Widgets key """ + @schema_extractor("schema") def validator(value): + if value is SCHEMA_EXTRACT: + # The widgets: list is built per-value at validation time, so the + # language-schema dumper sees nothing. Enumerate every registered + # widget type as an optional key (a widget item is really a + # single-key mapping; over-listing them lets editors complete any + # widget — `esphome config` enforces exactly one). extras carries the + # layout child options where applicable. + return cv.ensure_list( + cv.Schema( + { + cv.Optional(name): container_schema_value(widget_type, extras) + for name, widget_type in WIDGET_TYPES.items() + } + ) + ) if isinstance(value, dict): # Convert to list is_dict = True diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index ffbe29d701..dafdd91eb5 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) {} @@ -50,19 +50,10 @@ class LVGLSelect : public select::Select, public Component { protected: void control(size_t index) override { this->widget_->set_selected_index(index, this->anim_); - this->publish(); - } - void set_options_() { - // Widget uses std::vector, SelectTraits uses FixedVector - // Convert by extracting c_str() pointers - const auto &opts = this->widget_->get_options(); - FixedVector opt_ptrs; - opt_ptrs.init(opts.size()); - for (const auto &opt : opts) { - opt_ptrs.push_back(opt.c_str()); - } - this->traits.set_options(opt_ptrs); + // The update event fires the widget's on_value/on_update triggers + lv_obj_send_event(this->widget_->obj, lv_update_event, nullptr); } + void set_options_() { this->traits.set_options(this->widget_->get_options()); } LvSelectable *widget_; lv_anim_enable_t anim_; diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index c1441526f9..ad42028327 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -9,11 +9,19 @@ from .defines import ( CONF_THEME, LValidator, add_lv_use, + get_styles_used, + get_theme_update_requests, get_theme_widget_map, literal, ) from .lvcode import LambdaContext, lv -from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, WIDGET_TYPES, remap_property +from .schemas import ( + ALL_STYLES, + FULL_STYLE_SCHEMA, + WIDGET_TYPES, + remap_property, + theme_update_schema, +) from .types import ObjUpdateAction, lv_style_t from .widgets import collect_parts, wait_for_widgets @@ -25,6 +33,7 @@ def has_style_props(config) -> bool: async def style_set(svar, style): for prop, validator in ALL_STYLES.items(): if (value := style.get(prop)) is not None: + get_styles_used().add(prop) if isinstance(validator, LValidator): value = await validator.process(value) if isinstance(value, list): @@ -84,23 +93,91 @@ async def style_update_to_code(config, action_id, template_arg, args): style = await cg.get_variable(config[CONF_ID]) async with LambdaContext(parameters=args, where=action_id) as context: await style_set(style, config) + # Refresh and redraw every widget using this style -- otherwise the + # updated properties would sit unused until something else happens to + # invalidate the affected widgets. + lv.obj_report_style_change(style) return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) async def theme_to_code(config): - if theme := config.get(CONF_THEME): - add_lv_use(CONF_THEME) - for w_name, style in ((k, v) for k, v in theme.items() if k in WIDGET_TYPES): - # Work around Python 3.10 bug with nested async comprehensions - # With Python 3.11 this could be simplified - # TODO: Now that we require Python 3.11+, this can be updated to use nested comprehensions - styles = {} - for part, states in collect_parts(style).items(): - styles[part] = { - state: await create_style( + theme = config.get(CONF_THEME) or {} + requests = get_theme_update_requests() + # Iterate in WIDGET_TYPES' (deterministic, registration-order) sequence rather + # than a set -- a set of strings/tuples iterates in an order that depends on + # per-process hash randomization, which would otherwise churn the order hidden + # style variables are declared in main.cpp between builds of the same config. + widget_names = [ + w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests + ] + if not widget_names: + return + add_lv_use(CONF_THEME) + theme_map = get_theme_widget_map() + for w_name in widget_names: + declared_parts = collect_parts(theme[w_name]) if w_name in theme else {} + parts = {part: dict(states) for part, states in declared_parts.items()} + for part, state in requests.get(w_name, {}): + parts.setdefault(part, {}).setdefault(state, {}) + widget_styles = theme_map.setdefault(w_name, {}) + for part, states in parts.items(): + part_styles = widget_styles.setdefault(part, {}) + declared_states = declared_parts.get(part, {}) + for state, props in states.items(): + if state not in part_styles: + part_styles[state] = await create_style( "_lv_theme_style_" + w_name + "_" + part + "_" + state, props ) - for state, props in states.items() - } - get_theme_widget_map()[w_name] = styles + elif state in declared_states: + # A `theme.update` request for this combo (possibly from + # another LVGL instance) already created the style as an + # empty placeholder before this instance's real `theme:` + # declaration was reached -- apply the real values now + # instead of silently leaving it empty. + await style_set(part_styles[state], props) + + +@automation.register_action( + "lvgl.theme.update", + ObjUpdateAction, + theme_update_schema, + synchronous=True, +) +async def theme_update_to_code(config, action_id, template_arg, args): + await wait_for_widgets() + theme_map = get_theme_widget_map() + # Invariant this relies on: theme_update_schema() records every (widget + # type, part, state) combo this action targets as a request during config + # validation (which completes for the whole config tree before any + # to_code runs), and theme_to_code() -- which runs for every LVGL + # instance before any action's own to_code -- materialises a style for + # each recorded request. If that handshake is ever broken by a future + # change, fail with a diagnosable message rather than a bare KeyError. + to_update = [] + for w_name, style in config.items(): + for part, states in collect_parts(style).items(): + for state, props in states.items(): + # collect_parts() unconditionally seeds an (empty) main/default + # entry even when this action didn't target it -- skip it, both + # because there's nothing to update and because + # theme_update_schema no longer pre-creates a placeholder style + # for combos with no properties. + if not props: + continue + style_var = theme_map.get(w_name, {}).get(part, {}).get(state) + if style_var is None: + raise cv.Invalid( + f"No theme style exists for '{w_name}' {part}/{state}. " + "This is an internal error -- please report it." + ) + to_update.append((style_var, props)) + async with LambdaContext(parameters=args, where=action_id) as context: + for style_var, props in to_update: + await style_set(style_var, props) + # Refresh and redraw every widget using this style -- otherwise the + # updated properties would sit unused until something else happens + # to invalidate the affected widgets. + lv.obj_report_style_change(style_var) + + return cg.new_Pvariable(action_id, template_arg, await context.get_lambda()) 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/trigger.py b/esphome/components/lvgl/trigger.py index 5f524969e2..56dcf81a79 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -59,7 +59,10 @@ async def generate_triggers(): all_triggers = ( LV_EVENT_TRIGGERS + LV_DISPLAY_EVENT_TRIGGERS + LV_SCREEN_EVENT_TRIGGERS ) - for w in get_widget_map().values(): + # Snapshot: building a trigger below can recurse into widget creation (e.g. a + # buttonmatrix's or tabview's to_code registers its own child widgets), which + # would otherwise mutate this dict mid-iteration. + for w in list(get_widget_map().values()): config = w.config if isinstance(w.type, LvScrActType): w = get_screen_active(w.var) @@ -141,7 +144,21 @@ def _get_event_literal(trigger: str | MockObj) -> MockObj: return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()]) -async def add_trigger(conf, w, *events: str | MockObj, is_selected=None): +async def add_trigger( + conf, w, *events: str | MockObj, is_selected=None, attach_obj=None, user_data=None +): + """ + :param attach_obj: The object to actually register the callback on, if different + from `w.obj` - used when `w.obj` isn't valid at the point the callback gets + registered (e.g. a local variable that's only in scope inside the very + block this is called from, not from within the callback body itself; see + widgets/lv_list.py's dynamic widget creation). Defaults to `w.obj`. + :param user_data: Opaque pointer passed through to the registered event callback, + retrievable inside it via `lv_event_get_user_data(event)` - used to recover a + compound widget's C++ wrapper, which a captureless callback has no other way + to reach when it isn't a global variable (see widgets/lv_list.py). Defaults to + `nullptr`. + """ is_selected = is_selected or w.is_selected() tid = conf[CONF_TRIGGER_ID] trigger = cg.new_Pvariable(tid) @@ -158,12 +175,14 @@ async def add_trigger(conf, w, *events: str | MockObj, is_selected=None): lv_add(trigger.trigger(*value, literal("event"))) callback = await context.get_lambda() event_literals = [_get_event_literal(event) for event in events] + attach_obj = w.obj if attach_obj is None else attach_obj + user_data = nullptr if user_data is None else user_data if str(events[0]) in DISPLAY_TRIGGERS: assert len(events) == 1 lv.display_add_event_cb( - lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr + lv_expr.obj_get_display(attach_obj), callback, event_literals[0], user_data ) else: lv_add( - lvgl_static.add_event_cb(w.obj, await context.get_lambda(), *event_literals) + lvgl_static.add_event_cb(attach_obj, callback, *event_literals, user_data) ) diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 509d5cc782..cc8d9438a9 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -3,6 +3,8 @@ from esphome.const import CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component, esphome_ns +from .defines import CONF_SELECTED_INDEX + class LvType(cg.MockObjClass): def __init__(self, *args, **kwargs): @@ -67,6 +69,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") @@ -111,3 +114,4 @@ class LvSelect(LvType): parents=parens, **kwargs, ) + self.value_property = CONF_SELECTED_INDEX diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index ab1c61ff88..c9099e3c3a 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,6 @@ +from collections.abc import Callable import sys +from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action @@ -15,6 +17,7 @@ from esphome.const import ( from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable from esphome.cpp_generator import MockObj +from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( @@ -33,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, @@ -73,6 +75,34 @@ from ..types import ( EVENT_LAMB = "event_lamb__" +def _build_update_schema(widget_type: "WidgetType") -> Schema: + # Local import: ..schemas imports WidgetType from this module. + from ..schemas import base_update_schema + + return base_update_schema(widget_type, widget_type.parts).extend( + widget_type.modify_schema + ) + + +def _update_action_schema( + widget_type: "WidgetType", +) -> Schema | Callable[[Any], Any]: + # Eager when extracting so build_language_schema.py sees the mapping; + # lazy otherwise to skip ~200 ms of import-time voluptuous work. + if EnableSchemaExtraction: + return _build_update_schema(widget_type) + + cached: Schema | None = None + + def validator(value: Any) -> Any: + nonlocal cached + if cached is None: + cached = _build_update_schema(widget_type) + return cached(value) + + return validator + + class WidgetType: """ Describes a type of Widget, e.g. "bar" or "line" @@ -113,18 +143,17 @@ class WidgetType: # Local import to avoid circular import from ..automation import update_to_code - from ..schemas import WIDGET_TYPES, base_update_schema + from ..schemas import WIDGET_TYPES if not is_mock: if self.name in WIDGET_TYPES: raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") WIDGET_TYPES[self.name] = self - # Register the update action automatically, adding widget-specific properties register_action( f"lvgl.{self.name}.update", ObjUpdateAction, - base_update_schema(self, self.parts).extend(self.modify_schema), + _update_action_schema(self), synchronous=True, )(update_to_code) @@ -161,18 +190,7 @@ class WidgetType: await self.on_create(var, config) w = Widget.create(wid, var, self, config) - if theme := get_theme_widget_map().get(self.name): - for part, states in theme.items(): - part = "LV_PART_" + part.upper() - for state, style in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) - w.add_style(style, lv_state) + apply_theme_styles(w) await set_obj_properties(w, config) await add_widgets(w, config) await self.to_code(w, config) @@ -201,7 +219,7 @@ class WidgetType: :param config: Its configuration """ - def get_uses(self): + def get_uses(self) -> tuple: """ Get a list of other widgets used by this one :return: @@ -238,6 +256,21 @@ class WidgetType: """ +def apply_theme_styles(w: "Widget") -> None: + """Apply the current theme's styles for this widget's type""" + for part, states in get_theme_widget_map().get(w.type.name, {}).items(): + part = "LV_PART_" + part.upper() + for state, style in states.items(): + state = "LV_STATE_" + state.upper() + if state == "LV_STATE_DEFAULT": + lv_state = literal(part) + elif part == "LV_PART_MAIN": + lv_state = literal(state) + else: + lv_state = join_enums((state, part)) + w.add_style(style, lv_state) + + class Widget: """ Represents a Widget. @@ -260,6 +293,7 @@ class Widget: # Properties for linear equations self.slope = None self.y_int = None + self.parent = None @staticmethod def create(name, var, wtype: WidgetType, config: dict = None): @@ -510,44 +544,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/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717..54c9819d2b 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -1,3 +1,4 @@ +from esphome.components.const import CONF_LABEL import esphome.config_validation as cv from esphome.const import CONF_TEXT @@ -14,8 +15,6 @@ from ..schemas import TEXT_SCHEMA from ..types import LvText from . import Widget, WidgetType -CONF_LABEL = "label" - class LabelType(WidgetType): def __init__(self): diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py new file mode 100644 index 0000000000..83cbfb5ef9 --- /dev/null +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -0,0 +1,553 @@ +from collections.abc import Generator +from dataclasses import dataclass, field +from typing import Any + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_BUTTON, + CONF_ID, + CONF_INDEX, + CONF_ON_BOOT, + CONF_ON_UPDATE, + CONF_ON_VALUE, + CONF_TEXT, + CONF_TRIGGER_ID, +) +from esphome.core import CORE +from esphome.coroutine import FakeAwaitable +from esphome.cpp_generator import MockObj +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor + +from ..automation import action_to_code +from ..defines import ( + CONF_ALIGN_TO, + CONF_MAIN, + CONF_PAD_ROW, + CONF_SCROLLBAR, + CONF_WIDGETS, + LV_EVENT_TRIGGERS, + SWIPE_TRIGGERS, + TYPE_FLEX, + add_lv_use, + literal, +) +from ..lv_validation import lv_int, lv_text, padding +from ..lvcode import ( + UPDATE_EVENT, + LocalVariable, + LvConditional, + LvCountdown, + lv, + lv_add, + lv_expr, + lv_obj, +) +from ..schemas import ( + ALL_STYLES, + WIDGET_TYPES, + any_widget_schema, + apply_style_driven_defines, + container_schema_value, + remap_property, +) +from ..trigger import add_trigger +from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t +from . import ( + Widget, + WidgetType, + apply_theme_styles, + collect_parts, + get_widgets, + set_obj_properties, +) +from .buttonmatrix import CONF_BUTTONMATRIX +from .canvas import CONF_CANVAS +from .label import CONF_LABEL +from .meter import CONF_METER +from .tabview import CONF_TABVIEW +from .tileview import CONF_TILEVIEW + +CONF_LIST = "list" +CONF_WIDGET = "widget" +CONF_ON_ADD = "on_add" +CONF_ON_REMOVE = "on_remove" + +DOMAIN = "lvgl_list" + +lv_list_t = LvType("lv_list_t") + + +@dataclass +class ListTriggers: + on_add: list = field(default_factory=list) + on_remove: list = field(default_factory=list) + + +def _get_list_triggers(list_id) -> ListTriggers: + """ + Trigger Pvariables built for a given list's `on_add`/`on_remove` config, indexed by the + list's own ID. + """ + triggers_by_list = CORE.data.setdefault(DOMAIN, {}) + return triggers_by_list.setdefault(list_id, ListTriggers()) + + +def _get_pending_list_triggers(list_id) -> ListTriggers: + """ + Same shape as _get_list_triggers(), but holding raw on_add/on_remove automation + configs, not yet built. + """ + pending_by_list = CORE.data.setdefault(DOMAIN + "_pending", {}) + return pending_by_list.setdefault(list_id, ListTriggers()) + + +def _list_triggers_completed_flag() -> list[bool]: + return CORE.data.setdefault(DOMAIN + "_completed", [False]) + + +def _list_triggers_completed_generator() -> Generator[None, None, None]: + while True: + if _list_triggers_completed_flag()[0]: + return + yield + + +async def _wait_list_triggers_completed() -> None: + """Waits until finish_list_triggers() has built every list's on_add/on_remove automations.""" + if _list_triggers_completed_flag()[0]: + return + await FakeAwaitable(_list_triggers_completed_generator()) + + +async def finish_list_triggers() -> None: + """ + Builds every list's on_add/on_remove automations, collected by ListType.to_code() + instead of being built there directly. Must run after set_widgets_completed(True). + """ + for list_id, pending in CORE.data.get(DOMAIN + "_pending", {}).items(): + triggers = _get_list_triggers(list_id) + for conf in pending.on_add: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) + triggers.on_add.append(trigger) + for conf in pending.on_remove: + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + await automation.build_automation(trigger, [(cg.int_, "list_index")], conf) + triggers.on_remove.append(trigger) + _list_triggers_completed_flag()[0] = True + + +def _fire_index_triggers(triggers: list, index) -> None: + for trigger in triggers: + lv_add(trigger.trigger(index)) + + +async def _fire_on_add(list_id, list_obj, entry_obj) -> None: + await _wait_list_triggers_completed() + triggers = _get_list_triggers(list_id).on_add + if not triggers: + return + index = cg.RawExpression(f"lvgl::lv_list_get_row_index({list_obj}, {entry_obj})") + _fire_index_triggers(triggers, index) + + +async def _fire_on_remove(list_id, index) -> None: + await _wait_list_triggers_completed() + _fire_index_triggers(_get_list_triggers(list_id).on_remove, index) + + +LIST_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PAD_ROW): padding, + } +) + +LIST_CREATE_SCHEMA = LIST_SCHEMA.extend( + { + cv.Optional(CONF_ON_ADD): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + automation.Trigger.template(cg.int_) + ), + } + ), + cv.Optional(CONF_ON_REMOVE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + automation.Trigger.template(cg.int_) + ), + } + ), + } +) + + +class ListType(WidgetType): + """A plain wrapper around LVGL's native `lv_list`""" + + def __init__(self): + super().__init__( + CONF_LIST, + lv_list_t, + (CONF_MAIN, CONF_SCROLLBAR), + LIST_CREATE_SCHEMA, + modify_schema=LIST_SCHEMA, + ) + + def get_uses(self): + return TYPE_FLEX, CONF_LABEL, CONF_BUTTON + + async def to_code(self, w: Widget, config: dict): + on_add = config.get(CONF_ON_ADD, ()) + on_remove = config.get(CONF_ON_REMOVE, ()) + if not on_add and not on_remove: + return + pending = _get_pending_list_triggers(w.config[CONF_ID]) + pending.on_add.extend(on_add) + pending.on_remove.extend(on_remove) + + +list_spec = ListType() + +LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) + + +@automation.register_action( + "lvgl.list.add_text", + ObjUpdateAction, + LIST_ID_SCHEMA.extend( + { + cv.Required(CONF_TEXT): lv_text, + cv.Optional(CONF_INDEX): cv.templatable(cv.int_), + } + ), + synchronous=True, +) +async def list_add_text_to_code(config, action_id, template_arg, args): + widgets = await get_widgets(config) + + async def do_add_text(w: Widget): + text = await lv_text.process(config[CONF_TEXT]) + with LocalVariable( + "list_entry", lv_obj_t, lv_expr.list_add_text(w.obj, text) + ) as entry: + if (idx := config.get(CONF_INDEX)) is not None: + lv.obj_move_to_index(entry, await lv_int.process(idx)) + await _fire_on_add(config[CONF_ID], w.obj, entry) + + return await action_to_code( + widgets, do_add_text, action_id, template_arg, args, config + ) + + +_DYNAMIC_WIDGET_UNSUPPORTED = ( + CONF_BUTTONMATRIX, + CONF_TABVIEW, + CONF_TILEVIEW, + CONF_METER, + CONF_CANVAS, +) + + +def _check_dynamic_widget_supported(w_type_name: str, w_conf: dict) -> None: + # Each of these allocates a Pvariable, or registers children into the global widget + # map, once at boot - rebuilding them on every lvgl.list.add call would break that. + if w_type_name in _DYNAMIC_WIDGET_UNSUPPORTED: + raise cv.Invalid( + f"'{w_type_name}' cannot be used with lvgl.list.add - it manages its own " + "child widgets in a way that isn't compatible with widgets created at runtime" + ) + for child in w_conf.get(CONF_WIDGETS, ()): + [(child_type, child_conf)] = child.items() + _check_dynamic_widget_supported(child_type, child_conf) + + +_UNSUPPORTED_DYNAMIC_KEYS = SWIPE_TRIGGERS + (CONF_ON_BOOT, CONF_ALIGN_TO) + + +def _check_no_unsupported_triggers(w_type_name: str, w_conf: dict) -> None: + # These triggers currently aren't supporte for dynamic widgets + for key in _UNSUPPORTED_DYNAMIC_KEYS: + if key in w_conf: + raise cv.Invalid( + f"'{key}' is not supported on a widget added via lvgl.list.add - it " + "would validate but generate nothing, since it's only wired for " + "widgets that exist at boot", + path=[w_type_name, key], + ) + for child in w_conf.get(CONF_WIDGETS, ()): + [(child_type, child_conf)] = child.items() + _check_no_unsupported_triggers(child_type, child_conf) + + +def _check_no_explicit_widget_id(raw_value: dict) -> None: + for w_type_name, w_conf in raw_value.items(): + if not isinstance(w_conf, dict): + continue + if CONF_ID in w_conf: + raise cv.Invalid( + "'id' is not allowed on a widget added via lvgl.list.add - it is " + "rebuilt fresh on every call and never registered anywhere it " + "could be looked up by", + path=[w_type_name, CONF_ID], + ) + for child in w_conf.get(CONF_WIDGETS, ()): + if isinstance(child, dict): + _check_no_explicit_widget_id(child) + + +@schema_extractor("schema") +def list_add_schema(value: Any) -> Any: + # A plain cv.Schema can't express "id, an optional index, plus exactly one arbitrary + # widget-type key", since the set of widget types isn't fixed until validation time. + if value is SCHEMA_EXTRACT: + return LIST_ID_SCHEMA.extend( + { + cv.Optional(CONF_INDEX): cv.templatable(cv.int_), + **{ + cv.Optional(name): container_schema_value(widget_type) + for name, widget_type in WIDGET_TYPES.items() + }, + } + ) + if not isinstance(value, dict): + raise cv.Invalid("Expected a mapping") + value = value.copy() + if CONF_ID not in value: + raise cv.Invalid(f"required key '{CONF_ID}' not provided") + with cv.prepend_path([CONF_ID]): + list_id = cv.use_id(lv_list_t)(value.pop(CONF_ID)) + result = {CONF_ID: list_id} + if CONF_INDEX in value: + with cv.prepend_path([CONF_INDEX]): + result[CONF_INDEX] = cv.templatable(cv.int_)(value.pop(CONF_INDEX)) + if len(value) != 1: + raise cv.Invalid( + "lvgl.list.add takes exactly one widget definition, e.g. 'label:' or 'button:', alongside 'id' and optional 'index'" + ) + _check_no_explicit_widget_id(value) + result[CONF_WIDGET] = any_widget_schema()(value) + [(w_type_name, w_conf)] = result[CONF_WIDGET][0].items() + _check_dynamic_widget_supported(w_type_name, w_conf) + _check_no_unsupported_triggers(w_type_name, w_conf) + return result + + +def _register_lv_uses(w_type_name: str, w_conf: dict) -> None: + # Must run before this coroutine's first await. + widget_type = WIDGET_TYPES[w_type_name] + add_lv_use(w_type_name) + add_lv_use(*widget_type.get_uses()) + for child in w_conf.get(CONF_WIDGETS, ()): + [(child_type, child_conf)] = child.items() + _register_lv_uses(child_type, child_conf) + + +def _register_dynamic_widget_style_uses(w_conf: dict) -> None: + props = { + remap_property(prop) + for part_states in collect_parts(w_conf).values() + for state_props in part_states.values() + for prop in state_props + if prop in ALL_STYLES + } + apply_style_driven_defines(props) + for child in w_conf.get(CONF_WIDGETS, ()): + [(_, child_conf)] = child.items() + _register_dynamic_widget_style_uses(child_conf) + + +@automation.register_action( + "lvgl.list.add", + ObjUpdateAction, + list_add_schema, + synchronous=True, +) +async def list_add_to_code(config, action_id, template_arg, args): + [(w_type_name, w_conf)] = config[CONF_WIDGET][0].items() + _register_lv_uses(w_type_name, w_conf) + _register_dynamic_widget_style_uses(w_conf) + widgets = await get_widgets(config) + + async def do_add(w: Widget): + index = None + if (idx := config.get(CONF_INDEX)) is not None: + index = await lv_int.process(idx) + await _build_dynamic_widget( + w_type_name, + w_conf, + w.obj, + config[CONF_ID], + w.obj, + top_level=True, + index=index, + ) + + return await action_to_code(widgets, do_add, action_id, template_arg, args, config) + + +async def _build_dynamic_widget( + w_type_name: str, + w_conf: dict, + parent, + list_id, + list_obj, + top_level: bool = False, + index=None, + depth: int = 0, +) -> None: + # Builds one widget (recursively, with children and triggers) as a LocalVariable + # instead of a global Pvariable. Compound + # widgets are heap-allocated and freed via LV_EVENT_DELETE. + # `depth` suffixes the local variable's name below the row's top level. + widget_type = WIDGET_TYPES[w_type_name] + var_name = f"dyn_{w_type_name}" if depth == 0 else f"dyn_{w_type_name}_{depth}" + add_lv_use(w_type_name) + add_lv_use(*widget_type.get_uses()) + + async def finish_and_fire(w: Widget) -> None: + # Shared tail for both branches below - must run while var's LocalVariable + # block (opened by whichever branch calls this) is still open + await _finish_dynamic_widget(w, w_conf, list_id, list_obj, depth) + if top_level: + if index is not None: + lv.obj_move_to_index(w.obj, index) + await _fire_on_add(list_id, list_obj, w.obj) + + if widget_type.is_compound(): + with LocalVariable( + var_name, widget_type.w_type, widget_type.w_type.new() + ) as var: + creator = await widget_type.obj_creator(parent, w_conf) + lv_add(var.set_obj(creator)) + w = Widget(var, widget_type, w_conf) + lv_obj.add_event_cb( + w.obj, + literal(f"lvgl::delete_lv_compound_on_delete<{widget_type.w_type}>"), + literal("LV_EVENT_DELETE"), + var, + ) + await finish_and_fire(w) + else: + creator = await widget_type.obj_creator(parent, w_conf) + with LocalVariable(var_name, lv_obj_t, creator) as var: + w = Widget(var, widget_type, w_conf) + await finish_and_fire(w) + + +async def _finish_dynamic_widget( + w: Widget, w_conf: dict, list_id, list_obj, depth: int = 0 +) -> None: + await w.type.on_create(w.obj, w_conf) + apply_theme_styles(w) + await set_obj_properties(w, w_conf) + await w.type.to_code(w, w_conf) + await _wire_dynamic_triggers(w, w_conf) + for child in w_conf.get(CONF_WIDGETS, ()): + [(child_type, child_conf)] = child.items() + await _build_dynamic_widget( + child_type, child_conf, w.obj, list_id, list_obj, depth=depth + 1 + ) + + +async def _wire_dynamic_triggers(w: Widget, config: dict) -> None: + # Mirrors generate_triggers(), but runs immediately + if w.type.is_compound(): + event_var = MockObj( + f"static_cast<{w.type.w_type} *>(lv_event_get_user_data(event))", "->" + ) + user_data = w.var + else: + event_var = literal("static_cast(lv_event_get_target(event))") + user_data = None + event_target = Widget(event_var, w.type, config) + for event, conf in { + event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS + }.items(): + w.add_flag("LV_OBJ_FLAG_CLICKABLE") + await add_trigger( + conf[0], event_target, event, attach_obj=w.obj, user_data=user_data + ) + for conf in config.get(CONF_ON_VALUE, ()): + await add_trigger( + conf, + event_target, + LV_EVENT.VALUE_CHANGED, + UPDATE_EVENT, + attach_obj=w.obj, + user_data=user_data, + ) + for conf in config.get(CONF_ON_UPDATE, ()): + await add_trigger( + conf, event_target, UPDATE_EVENT, attach_obj=w.obj, user_data=user_data + ) + + +LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( + { + # positive_int, not int_: a negative index would silently delete the *last* + # row (lv_obj_get_child() counts back from the end) while reporting that + # same bogus value to on_remove's list_index. + cv.Required(CONF_INDEX): cv.templatable(cv.positive_int), + } +) + + +@automation.register_action( + "lvgl.list.remove", + ObjUpdateAction, + LIST_REMOVE_SCHEMA, + synchronous=True, +) +async def list_remove_to_code(config, action_id, template_arg, args): + widgets = await get_widgets(config) + + async def do_remove(w: Widget): + index = await lv_int.process(config[CONF_INDEX]) + # Materialised into a local since index is needed at two call sites below, and + # a lambda's body gets re-emitted (and re-run) at every point it's used. + with ( + LocalVariable("list_index", cg.int_, index, modifier="") as idx, + # Out-of-range lookup/log lives in a shared C++ helper, not inline here: + # a config can have many lvgl.list.remove call sites. + LocalVariable( + "list_child", + lv_obj_t, + cg.RawExpression(f"lvgl::lv_list_get_row_for_remove({w.obj}, {idx})"), + ) as child, + LvConditional(child), + ): + await _fire_on_remove(config[CONF_ID], idx) + # Recursively destroys the whole subtree + lv.obj_del(child) + + return await action_to_code( + widgets, do_remove, action_id, template_arg, args, config + ) + + +@automation.register_action( + "lvgl.list.clear", + ObjUpdateAction, + LIST_ID_SCHEMA, + synchronous=True, +) +async def list_clear_to_code(config, action_id, template_arg, args): + widgets = await get_widgets(config) + + async def do_clear(w: Widget): + await _wait_list_triggers_completed() + triggers = _get_list_triggers(config[CONF_ID]).on_remove + if triggers: + # Fire on_remove for every entry, newest to oldest, before wiping them all out, + # so on_remove's semantics ("an entry left the list") hold + with LvCountdown("list_index", lv_expr.obj_get_child_count(w.obj)) as index: + _fire_index_triggers(triggers, index) + # lv_obj_clean recursively destroys every child's whole subtree + lv.obj_clean(w.obj) + + return await action_to_code( + widgets, do_clear, action_id, template_arg, args, config + ) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 62ea14bdda..166e88f382 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -184,6 +184,7 @@ INDICATOR_ARC_SCHEMA = cv.Schema( cv.Optional(CONF_START_VALUE): lv_float, cv.Optional(CONF_END_VALUE): lv_float, cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Optional(CONF_ROUNDED, default=False): cv.boolean, } ).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE)) @@ -417,7 +418,7 @@ class MeterType(WidgetType): "arc_width": v[CONF_WIDTH], "arc_color": v[CONF_COLOR], "arc_opa": v[CONF_OPA], - "arc_rounded": v.get("arc_rounded", False), + "arc_rounded": v[CONF_ROUNDED], } if CONF_R_MOD in v: get_warnings().add( @@ -429,7 +430,8 @@ class MeterType(WidgetType): tvar, LV_PART.MAIN, await arc_style.get_var() ) lw = Widget.create(iid, tvar, arc_indicator_type) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_TICK_STYLE: # No object created for this @@ -481,7 +483,8 @@ class MeterType(WidgetType): if option in v: props["line_" + option] = v[option] lw = await widget_to_code(props, line_indicator_type, scale_var) - await set_indicator_values(lw, v) + lw.parent = scale_var + await set_indicator_values(scale_var, lw, v) if t == CONF_IMAGE: add_lv_use(CONF_IMAGE) @@ -500,7 +503,8 @@ class MeterType(WidgetType): } iw = await widget_to_code(props, image_indicator_type, scale_var) await iw.set_property(CONF_SRC, await lv_image.process(src)) - await set_indicator_values(iw, v) + iw.parent = scale_var + await set_indicator_values(scale_var, iw, v) # Hide the scale line lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN) @@ -606,27 +610,27 @@ async def indicator_update_to_code(config, action_id, template_arg, args): widget = await get_widgets(config) async def set_value(w: Widget): - await set_indicator_values(w, config) + await set_indicator_values(w.parent, w, config) return await action_to_code( widget, set_value, action_id, template_arg, args, config ) -async def set_indicator_values(indicator: Widget, config): +async def set_indicator_values(scale: MockObj, indicator: Widget, config): """Update scale section values (replaces meter indicator values)""" start_value = await get_start_value(config) end_value = await get_end_value(config) if indicator.type is arc_indicator_type: # For scale sections, we update the range if start_value is not None and end_value is not None: - lv.scale_section_set_range(indicator.obj, start_value, end_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, end_value) elif start_value is not None: # If only start value, use it as both start and end (single point) - lv.scale_section_set_range(indicator.obj, start_value, start_value) + lv.scale_set_section_range(scale, indicator.obj, start_value, start_value) elif end_value is not None: # If only end value, assume range from 0 to end_value - lv.scale_section_set_range(indicator.obj, 0, end_value) + lv.scale_set_section_range(scale, indicator.obj, 0, end_value) return if start_value is None: diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py new file mode 100644 index 0000000000..efae2be2be --- /dev/null +++ b/esphome/components/lvgl/widgets/table.py @@ -0,0 +1,280 @@ +from contextlib import ExitStack + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigFragmentType, ConfigType, SafeExpType + +from ..automation import action_to_code +from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal +from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator +from ..lvcode import LocalVariable, lv, lv_add, lv_expr +from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t +from . import Widget, WidgetType, get_widgets +from .label import CONF_LABEL + +CONF_TABLE = "table" +CONF_CELLS = "cells" +CONF_COLUMNS = "columns" +CONF_ROW_COUNT = "row_count" +CONF_COLUMN_COUNT = "column_count" +CONF_MERGE_RIGHT = "merge_right" +CONF_TEXT_CROP = "text_crop" +CONF_SELECTED_ROW = "selected_row" +CONF_SELECTED_COLUMN = "selected_column" + +CELL_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TEXT, default=""): lv_text, + # Not templatable: the value selects between two different LVGL calls + # (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call. + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } +) + +# A cell can be given as a bare piece of text, or a dict for more control +TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT) + +# A row can be given as a bare list of cells, or a dict for future extension +ROW_SCHEMA = cv.maybe_simple_value( + cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}), + key=CONF_CELLS, +) + + +def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]: + """Like pixels_or_percent, but rejects negative widths, which would + defeat the 100%-total check and wrap around in the generated uint8_t pct.""" + if value == SCHEMA_EXTRACT: + return ["pixels", "..%"] + return cv.Any(pixels_validator, cv.percentage)(value) + + +column_width = LValidator( + _column_width_validator, + lv_coord_t, + retmapper=pixels_or_percent.retmapper, + animatable=True, +) + +COLUMN_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIDTH): column_width, + } +) + + +def _validate_table(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS) + min_row_count = len(rows) if rows else 0 + min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0 + row_count = config.get(CONF_ROW_COUNT) + if row_count is not None and row_count < min_row_count: + raise cv.Invalid( + f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows", + path=[CONF_ROW_COUNT], + ) + column_count = config.get(CONF_COLUMN_COUNT) + if column_count is not None and column_count < min_column_count: + raise cv.Invalid( + f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row", + path=[CONF_COLUMN_COUNT], + ) + column_count = column_count if column_count is not None else min_column_count + columns = config.get(CONF_COLUMNS) + if columns and column_count and len(columns) > column_count: + raise cv.Invalid( + f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}", + path=[CONF_COLUMNS], + ) + total_pct = sum( + width + for column in columns or () + if isinstance((width := column.get(CONF_WIDTH)), float) + ) + if total_pct > 1.0: + raise cv.Invalid( + f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%", + path=[CONF_COLUMNS], + ) + return config + + +TABLE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA), + cv.Optional(CONF_ROW_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMN_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA), + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +).add_extra(_validate_table) + +lv_table_t = LvType( + "LvTableType", + parents=(LvCompound,), + largs=[(cg.uint32, "row"), (cg.uint32, "column")], + lvalue=lambda w: [ + lv_expr.table_get_selected_row(w.obj), + lv_expr.table_get_selected_column(w.obj), + ], + has_on_value=True, +) + + +async def set_cell_ctrl( + w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType +) -> None: + for key, ctrl in ( + (CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"), + (CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"), + ): + if key not in cell: + continue + if cell[key]: + lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl)) + else: + lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl)) + + +async def set_selected_cell(w: Widget, config: ConfigType) -> None: + selected_row = config.get(CONF_SELECTED_ROW) + selected_column = config.get(CONF_SELECTED_COLUMN) + if selected_row is None and selected_column is None: + return + # LV_TABLE_CELL_NONE selects the whole column/row when only one index is given + row_value = ( + await lv_int.process(selected_row) + if selected_row is not None + else literal("LV_TABLE_CELL_NONE") + ) + column_value = ( + await lv_int.process(selected_column) + if selected_column is not None + else literal("LV_TABLE_CELL_NONE") + ) + lv.table_set_selected_cell(w.obj, row_value, column_value) + + +TABLE_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +) + + +class TableType(WidgetType): + def __init__(self): + super().__init__( + CONF_TABLE, + lv_table_t, + (CONF_MAIN, CONF_ITEMS), + TABLE_SCHEMA, + modify_schema=TABLE_MODIFY_SCHEMA, + ) + + def get_uses(self) -> tuple[str]: + return (CONF_LABEL,) + + async def to_code(self, w: Widget, config: dict) -> None: + rows = config.get(CONF_ROWS) + row_count = config.get(CONF_ROW_COUNT) + column_count = config.get(CONF_COLUMN_COUNT) + if rows is not None: + if row_count is None: + row_count = len(rows) + if column_count is None: + column_count = max((len(row[CONF_CELLS]) for row in rows), default=0) + if row_count is not None: + lv.table_set_row_count(w.obj, row_count) + if column_count is not None: + lv.table_set_column_count(w.obj, column_count) + columns = config.get(CONF_COLUMNS, ()) + pct_column_count = sum( + 1 for column in columns if isinstance(column.get(CONF_WIDTH), float) + ) + if pct_column_count: + lv_add(w.var.init_column_pct(pct_column_count)) + for index, column in enumerate(columns): + if (width := column.get(CONF_WIDTH)) is None: + continue + if isinstance(width, float): + # A percentage: column_width validation leaves it as a 0.0-1.0 + # fraction. LVGL's table widget only accepts a literal pixel width, so + # the actual width is recomputed at runtime from the table's own size. + lv_add(w.var.add_column_width_pct(index, round(width * 100))) + else: + lv.table_set_column_width( + w.obj, index, await column_width.process(width) + ) + for row_index, row in enumerate(rows or ()): + for column_index, cell in enumerate(row[CONF_CELLS]): + lv.table_set_cell_value( + w.obj, + row_index, + column_index, + await lv_text.process(cell[CONF_TEXT]), + ) + await set_cell_ctrl(w, row_index, column_index, cell) + await set_selected_cell(w, config) + + +table_spec = TableType() + + +@automation.register_action( + "lvgl.table.cell.update", + ObjUpdateAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_table_t), + cv.Required(CONF_ROW): lv_int, + cv.Required(CONF_COLUMN): lv_int, + cv.Optional(CONF_TEXT): lv_text, + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } + ).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)), + synchronous=True, +) +async def table_cell_update_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + widgets = await get_widgets(config) + + async def do_update(w: Widget): + row = await lv_int.process(config[CONF_ROW]) + column = await lv_int.process(config[CONF_COLUMN]) + fields_set = sum( + key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP) + ) + with ExitStack() as stack: + if fields_set > 1: + # row/column feed more than one generated call below: cache them in + # local variables so a !lambda value is only evaluated once. + row = stack.enter_context( + LocalVariable("row", cg.int_, row, modifier="") + ) + column = stack.enter_context( + LocalVariable("column", cg.int_, column, modifier="") + ) + if CONF_TEXT in config: + lv.table_set_cell_value( + w.obj, row, column, await lv_text.process(config[CONF_TEXT]) + ) + await set_cell_ctrl(w, row, column, config) + + return await action_to_code( + widgets, do_update, action_id, template_arg, args, config + ) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 5e9e0494dd..ee252ecf0b 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -97,7 +97,7 @@ class TabviewType(WidgetType): tab_bar = Widget(bar_obj, obj_spec) await set_obj_properties(tab_bar, tab_style) if tab_items_style: - for index, tab_conf in enumerate(config[CONF_TABS]): + for index, _tab_conf in enumerate(config[CONF_TABS]): await set_obj_properties( Widget(lv_obj.get_child(bar_obj, index), button_spec), tab_items_style, diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index a1c197b381..6404bcf64c 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@rnauber"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(i2c.i2c_device_schema(0x43)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/m5stack_8angle/binary_sensor/__init__.py b/esphome/components/m5stack_8angle/binary_sensor/__init__.py index 22ab73e901..09398876d4 100644 --- a/esphome/components/m5stack_8angle/binary_sensor/__init__.py +++ b/esphome/components/m5stack_8angle/binary_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -22,7 +23,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) sens = await binary_sensor.new_binary_sensor(config) cg.add(sens.set_parent(hub)) 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/__init__.py b/esphome/components/m5stack_8angle/light/__init__.py index 806ecaabf4..5c4863acf7 100644 --- a/esphome/components/m5stack_8angle/light/__init__.py +++ b/esphome/components/m5stack_8angle/light/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import light import esphome.config_validation as cv from esphome.const import CONF_OUTPUT_ID +from esphome.types import ConfigType from .. import CONF_M5STACK_8ANGLE_ID, M5Stack8AngleComponent, m5stack_8angle_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_M5STACK_8ANGLE_ID]) lights = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(lights, config) 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/__init__.py b/esphome/components/m5stack_8angle/sensor/__init__.py index 2132eaa4c2..87d1425241 100644 --- a/esphome/components/m5stack_8angle/sensor/__init__.py +++ b/esphome/components/m5stack_8angle/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( ICON_ROTATE_RIGHT, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from .. import ( CONF_M5STACK_8ANGLE_ID, @@ -55,7 +56,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await cg.register_parented(var, config[CONF_M5STACK_8ANGLE_ID]) 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/mapping/__init__.py b/esphome/components/mapping/__init__.py index 3c7d78a27b..cd846877ae 100644 --- a/esphome/components/mapping/__init__.py +++ b/esphome/components/mapping/__init__.py @@ -1,5 +1,6 @@ from collections.abc import Callable import difflib +from typing import Any import esphome.codegen as cg from esphome.components.const import KEY_METADATA @@ -13,6 +14,7 @@ from esphome.cpp_generator import ( add_global, ) from esphome.loader import get_component +from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] MULTI_CONF = True @@ -32,13 +34,16 @@ class IndexType: """ def __init__( - self, validator: Callable, data_type: MockObj, conversion: Callable = None + self, + validator: Callable, + data_type: MockObj, + conversion: Callable | None = None, ) -> None: self.validator = validator self.data_type = data_type self.conversion = conversion - async def convert_value(self, value): + async def convert_value(self, value: Any) -> Any: if self.conversion: return self.conversion(value) return await cg.get_variable(value) @@ -60,7 +65,7 @@ class MappingMetaData: self.to_ = to_ -def to_schema(value): +def to_schema(value: Any) -> str: """ Generate a schema for the 'to' field of a map. This can be either one of the index types or a class name. :param value: @@ -82,7 +87,7 @@ BASE_SCHEMA = cv.Schema( ) -def get_object_type(to_) -> MockObjClass | None: +def get_object_type(to_: str) -> MockObjClass | None: """ Get the object type from a string. Possible formats: xxx The name of a component which defines INSTANCE_TYPE @@ -121,7 +126,7 @@ def add_metadata( get_all_mapping_metadata()[mapping_id.id] = MappingMetaData(from_, to_) -def map_schema(config): +def map_schema(config: ConfigType) -> ConfigType: config = BASE_SCHEMA(config) if CONF_ENTRIES not in config or not isinstance(config[CONF_ENTRIES], dict): raise cv.Invalid("an entries dictionary is required for a mapping") @@ -163,7 +168,7 @@ def map_schema(config): CONFIG_SCHEMA = map_schema -async def to_code(config): +async def to_code(config: ConfigType) -> MockObj: varid = config[CONF_ID] metadata = get_mapping_metadata(varid.id) entries = { diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 868b149211..47cf4793b1 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -4,6 +4,7 @@ from esphome.components import key_provider from esphome.components.const import CONF_ROWS import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID +from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] @@ -27,7 +28,7 @@ CONF_HAS_DIODES = "has_diodes" CONF_HAS_PULLDOWNS = "has_pulldowns" -def check_keys(obj): +def check_keys(obj: ConfigType) -> ConfigType: if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -62,7 +63,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) row_pins = [] diff --git a/esphome/components/matrix_keypad/binary_sensor/__init__.py b/esphome/components/matrix_keypad/binary_sensor/__init__.py index 8e63ed43ce..6c6e0aad73 100644 --- a/esphome/components/matrix_keypad/binary_sensor/__init__.py +++ b/esphome/components/matrix_keypad/binary_sensor/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import CONF_COL, CONF_ID, CONF_KEY, CONF_ROW +from esphome.types import ConfigType from .. import CONF_KEYPAD_ID, MatrixKeypad, matrix_keypad_ns @@ -12,7 +13,7 @@ MatrixKeypadBinarySensor = matrix_keypad_ns.class_( ) -def check_button(obj): +def check_button(obj: ConfigType) -> ConfigType: if CONF_ROW in obj or CONF_COL in obj: if CONF_KEY in obj: raise cv.Invalid("You can't provide both a key and a position") @@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if CONF_KEY in config: var = cg.new_Pvariable(config[CONF_ID], config[CONF_KEY][0]) else: 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/max17043/sensor.py b/esphome/components/max17043/sensor.py index ebb045dfce..67fb8aa5b7 100644 --- a/esphome/components/max17043/sensor.py +++ b/esphome/components/max17043/sensor.py @@ -14,6 +14,9 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -50,7 +53,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -74,6 +77,11 @@ MAX17043_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA, synchronous=True ) -async def max17043_sleep_mode_to_code(config, action_id, template_arg, args): +async def max17043_sleep_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) return cg.new_Pvariable(action_id, template_arg, paren) 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/max31855/sensor.py b/esphome/components/max31855/sensor.py index 35ae28d04c..a52f45a18f 100644 --- a/esphome/components/max31855/sensor.py +++ b/esphome/components/max31855/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType max31855_ns = cg.esphome_ns.namespace("max31855") MAX31855Sensor = max31855_ns.class_( @@ -36,7 +37,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/max31856/sensor.py b/esphome/components/max31856/sensor.py index 679e02b11d..43a2e18db8 100644 --- a/esphome/components/max31856/sensor.py +++ b/esphome/components/max31856/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType max31856_ns = cg.esphome_ns.namespace("max31856") MAX31856Sensor = max31856_ns.class_( @@ -58,7 +59,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/max31865/sensor.py b/esphome/components/max31865/sensor.py index d4498b062f..167a0997e4 100644 --- a/esphome/components/max31865/sensor.py +++ b/esphome/components/max31865/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType CODEOWNERS = ["@DAVe3283"] DEPENDENCIES = ["spi"] @@ -52,7 +53,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/max44009/sensor.py b/esphome/components/max44009/sensor.py index 5aea7f0be2..88673c5d00 100644 --- a/esphome/components/max44009/sensor.py +++ b/esphome/components/max44009/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_LUX, ) +from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] DEPENDENCIES = ["i2c"] @@ -44,7 +45,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/max6675/sensor.py b/esphome/components/max6675/sensor.py index e42abb68d1..94d857cab9 100644 --- a/esphome/components/max6675/sensor.py +++ b/esphome/components/max6675/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType max6675_ns = cg.esphome_ns.namespace("max6675") MAX6675Sensor = max6675_ns.class_( @@ -25,7 +26,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await spi.register_spi_device(var, config) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index e9fae4cceb..5e45d71899 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,6 +11,9 @@ from esphome.const import ( CONF_OUTPUT, CONF_PULLUP, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@looping40"] @@ -54,7 +57,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -62,7 +65,7 @@ async def to_code(config): cg.add(var.set_brightness_global(config[CONF_BRIGHTNESS_GLOBAL])) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -87,7 +90,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MAX6956, MAX6956_PIN_SCHEMA) -async def max6956_pin_to_code(config): +async def max6956_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MAX6956]) @@ -114,7 +117,12 @@ async def max6956_pin_to_code(config): ), synchronous=True, ) -async def max6956_set_brightness_global_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_global_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable(config[CONF_BRIGHTNESS_GLOBAL], args, cg.uint8) @@ -136,7 +144,12 @@ async def max6956_set_brightness_global_to_code(config, action_id, template_arg, ), synchronous=True, ) -async def max6956_set_brightness_mode_to_code(config, action_id, template_arg, args): +async def max6956_set_brightness_mode_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) template_ = await cg.templatable( 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/__init__.py b/esphome/components/max6956/output/__init__.py index 352ba04a95..f92bbb762a 100644 --- a/esphome/components/max6956/output/__init__.py +++ b/esphome/components/max6956/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PIN +from esphome.types import ConfigType from .. import CONF_MAX6956, MAX6956, max6956_ns @@ -20,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MAX6956]) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/display.py b/esphome/components/max7219/display.py index abb20702bd..b21f66b553 100644 --- a/esphome/components/max7219/display.py +++ b/esphome/components/max7219/display.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import display, spi import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_INTENSITY, CONF_LAMBDA, CONF_NUM_CHIPS +from esphome.types import ConfigType DEPENDENCIES = ["spi"] @@ -27,7 +28,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_NUM_CHIPS]) await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) 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/display.py b/esphome/components/max7219digit/display.py index df2423b0d0..54711263dd 100644 --- a/esphome/components/max7219digit/display.py +++ b/esphome/components/max7219digit/display.py @@ -10,6 +10,9 @@ from esphome.const import ( CONF_NUM_CHIPS, CONF_STATE, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@rspaargaren"] DEPENDENCIES = ["spi"] @@ -84,7 +87,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await spi.register_spi_device(var, config, write_only=True) await display.register_display(var, config) @@ -144,7 +147,12 @@ MAX7219_ON_ACTION_SCHEMA = automation.maybe_simple_id( MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_invert_to_code(config, action_id, template_arg, args): +async def max7219digit_invert_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -164,7 +172,12 @@ async def max7219digit_invert_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_visible_to_code(config, action_id, template_arg, args): +async def max7219digit_visible_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -184,7 +197,12 @@ async def max7219digit_visible_to_code(config, action_id, template_arg, args): MAX7219_ON_ACTION_SCHEMA, synchronous=True, ) -async def max7219digit_reverse_to_code(config, action_id, template_arg, args): +async def max7219digit_reverse_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) @@ -209,7 +227,12 @@ MAX7219_INTENSITY_SCHEMA = cv.maybe_simple_value( MAX7219_INTENSITY_SCHEMA, synchronous=True, ) -async def max7219digit_intensity_to_code(config, action_id, template_arg, args): +async def max7219digit_intensity_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) template_ = await cg.templatable(config[CONF_INTENSITY], args, cg.uint8) 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/max9611/sensor.py b/esphome/components/max9611/sensor.py index b3a73d8c10..9332274a95 100644 --- a/esphome/components/max9611/sensor.py +++ b/esphome/components/max9611/sensor.py @@ -19,6 +19,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] max9611_ns = cg.esphome_ns.namespace("max9611") @@ -70,7 +71,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp23008/__init__.py b/esphome/components/mcp23008/__init__.py index 8ff938114a..3d1480a6f7 100644 --- a/esphome/components/mcp23008/__init__.py +++ b/esphome/components/mcp23008/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, mcp23x08_base, mcp23xxx_base import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["mcp23x08_base"] CODEOWNERS = ["@jesserockz"] @@ -23,6 +24,6 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x08_base.NUM_PINS) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a..5f4b7276d8 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -1,6 +1,6 @@ from esphome import pins import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import gpio_expander, i2c import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -11,6 +11,8 @@ from esphome.const import ( CONF_NUMBER, CONF_OUTPUT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] @@ -25,7 +27,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), - cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) .extend(cv.COMPONENT_SCHEMA) @@ -33,7 +35,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) @@ -41,7 +43,7 @@ async def to_code(config): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -64,7 +66,7 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23016, MCP23016_PIN_SCHEMA) -async def mcp23016_pin_to_code(config): +async def mcp23016_pin_to_code(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) parent = await cg.get_variable(config[CONF_MCP23016]) 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/__init__.py b/esphome/components/mcp23017/__init__.py index e5cc1856eb..474d75f6ff 100644 --- a/esphome/components/mcp23017/__init__.py +++ b/esphome/components/mcp23017/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, mcp23x17_base, mcp23xxx_base import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["mcp23x17_base"] CODEOWNERS = ["@jesserockz"] @@ -23,6 +24,6 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x17_base.NUM_PINS) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/mcp23s08/__init__.py index 312da79b75..ffc51b8146 100644 --- a/esphome/components/mcp23s08/__init__.py +++ b/esphome/components/mcp23s08/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import mcp23x08_base, mcp23xxx_base, spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["mcp23x08_base"] CODEOWNERS = ["@SenexCrenshaw", "@jesserockz"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x08_base.NUM_PINS) cg.add(var.set_device_address(config[CONF_DEVICEADDRESS])) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/mcp23s17/__init__.py index 599bfa0851..d693a64ce8 100644 --- a/esphome/components/mcp23s17/__init__.py +++ b/esphome/components/mcp23s17/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import mcp23x17_base, mcp23xxx_base, spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType AUTO_LOAD = ["mcp23x17_base"] CODEOWNERS = ["@SenexCrenshaw", "@jesserockz"] @@ -26,7 +27,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await mcp23xxx_base.register_mcp23xxx(config, mcp23x17_base.NUM_PINS) cg.add(var.set_device_address(config[CONF_DEVICEADDRESS])) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3f..755d86e4ea 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -1,8 +1,8 @@ from esphome import pins import esphome.codegen as cg +from esphome.components import gpio_expander import esphome.config_validation as cv from esphome.const import ( - CONF_ALLOW_OTHER_USES, CONF_ID, CONF_INPUT, CONF_INTERRUPT, @@ -15,6 +15,8 @@ from esphome.const import ( CONF_PULLUP, ) from esphome.core import CORE, ID, coroutine +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] @@ -32,34 +34,16 @@ MCP23XXX_INTERRUPT_MODES = { } -def _validate_interrupt_pin(value): - # The MCP component owns INT polarity (active-low, hardcoded falling-edge ISR) - # and installs a single ISR per GPIO, so neither inversion nor sharing is supported. - value = pins.internal_gpio_input_pin_schema(value) - if value.get(CONF_INVERTED): - raise cv.Invalid( - f"'{CONF_INVERTED}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "the MCP23xxx INT line is fixed active-low" - ) - if value.get(CONF_ALLOW_OTHER_USES): - raise cv.Invalid( - f"'{CONF_ALLOW_OTHER_USES}: true' is not supported on '{CONF_INTERRUPT_PIN}'; " - "sharing the interrupt pin between multiple MCP23xxx (or other components) " - "is not implemented. Remove the interrupt_pin to fall back to polling." - ) - return value - - MCP23XXX_CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_OPEN_DRAIN_INTERRUPT, default=False): cv.boolean, - cv.Optional(CONF_INTERRUPT_PIN): _validate_interrupt_pin, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ).extend(cv.COMPONENT_SCHEMA) @coroutine -async def register_mcp23xxx(config, num_pins): +async def register_mcp23xxx(config: ConfigType, num_pins: int) -> MockObj: id: ID = config[CONF_ID] var = cg.new_Pvariable(id) await cg.register_component(var, config) @@ -70,7 +54,7 @@ async def register_mcp23xxx(config, num_pins): return var -def validate_mode(value): +def validate_mode(value: ConfigType) -> ConfigType: 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]: @@ -99,7 +83,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( @pins.PIN_SCHEMA_REGISTRY.register(CONF_MCP23XXX, MCP23XXX_PIN_SCHEMA) -async def mcp23xxx_pin_to_code(config): +async def mcp23xxx_pin_to_code(config: ConfigType) -> MockObj: parent_id: ID = config[CONF_MCP23XXX] parent = await cg.get_variable(parent_id) 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/canbus.py b/esphome/components/mcp2515/canbus.py index d34a77248c..8bb8918f96 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -3,6 +3,7 @@ from esphome.components import canbus, spi from esphome.components.canbus import CanbusComponent import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODE +from esphome.types import ConfigType CODEOWNERS = ["@mvturnho", "@danielschramm"] DEPENDENCIES = ["spi"] @@ -36,7 +37,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ).extend(spi.spi_device_schema(True)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: rhs = mcp2515.new() var = cg.Pvariable(config[CONF_ID], rhs) await canbus.register_canbus(var, config) 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/__init__.py b/esphome/components/mcp3008/__init__.py index 41ccdd403a..6d1bd5a970 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["spi"] AUTO_LOAD = ["sensor"] @@ -19,7 +20,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(spi.spi_device_schema(cs_pin_required=True)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await spi.register_spi_device(var, config) 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/__init__.py b/esphome/components/mcp3008/sensor/__init__.py index 2576ef50e5..de31f81345 100644 --- a/esphome/components/mcp3008/sensor/__init__.py +++ b/esphome/components/mcp3008/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType from .. import MCP3008, mcp3008_ns @@ -43,7 +44,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_parented(var, config[CONF_MCP3008_ID]) await cg.register_component(var, config) 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/__init__.py b/esphome/components/mcp3204/__init__.py index 612297f934..5757bdbfa9 100644 --- a/esphome/components/mcp3204/__init__.py +++ b/esphome/components/mcp3204/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import spi import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_REFERENCE_VOLTAGE +from esphome.types import ConfigType DEPENDENCIES = ["spi"] MULTI_CONF = True @@ -19,7 +20,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(spi.spi_device_schema(cs_pin_required=True)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) await cg.register_component(var, config) 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/__init__.py b/esphome/components/mcp3204/sensor/__init__.py index 5f9aa9fdb6..728a1c0611 100644 --- a/esphome/components/mcp3204/sensor/__init__.py +++ b/esphome/components/mcp3204/sensor/__init__.py @@ -2,6 +2,7 @@ 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_NUMBER +from esphome.types import ConfigType from .. import MCP3204, mcp3204_ns @@ -28,7 +29,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_NUMBER], 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/mcp3221/sensor.py b/esphome/components/mcp3221/sensor.py index 993876c2c8..30e972d808 100644 --- a/esphome/components/mcp3221/sensor.py +++ b/esphome/components/mcp3221/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_VOLT, ) +from esphome.types import ConfigType AUTO_LOAD = ["voltage_sampler"] DEPENDENCIES = ["i2c"] @@ -42,7 +43,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) await cg.register_component(var, config) diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index f3ef6f4917..60cece67d7 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] @@ -30,7 +31,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable( config[CONF_ID], config[CONF_DISABLE_WIPER_0], diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index 4573553664..abc74b9e6d 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -21,7 +21,18 @@ void Mcp4461Component::setup() { auto init_val = this->reg_[i].initial_value; if (init_val.has_value()) { uint16_t initial_state = static_cast(*init_val * 256.0f); - this->write_wiper_level_(i, initial_state); + if (i > 3) { + // NV wiper: an unconditional write would cost one EEPROM erase/write cycle on EVERY + // boot. Only write when the stored value actually differs — and always write when + // the read itself failed (a failed read returns 0, which would silently skip the + // write whenever initial_value is 0). + bool read_ok = false; + if (this->read_wiper_level_(i, &read_ok) != initial_state || !read_ok) { + this->write_wiper_level_(i, initial_state); + } + } else { + this->write_wiper_level_(i, initial_state); + } } if (this->reg_[i].enabled) { this->reg_[i].state = this->read_wiper_level_(i); @@ -34,6 +45,23 @@ void Mcp4461Component::setup() { } } } + // Push the YAML terminal configuration to the TCON registers. TCON is volatile — on POR + // the chip restores wiper levels from the NV registers but resets TCON to "all terminals + // connected", so any terminal_a/b/w disables from the config MUST be written here. + for (uint8_t t = 0; t < 2; t++) { + Mcp4461TerminalIdx terminal_connector = static_cast(t); + uint8_t terminal_byte = this->calc_terminal_connector_byte_(terminal_connector); + this->set_terminal_register_(terminal_connector, terminal_byte); + } +} + +void Mcp4461Component::set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms) { + uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + return; // NV channels E-H are the persistence target themselves + } + this->reg_[wiper_idx].nonvolatile = true; + this->reg_[wiper_idx].nonvolatile_write_delay_ms = write_delay_ms; } void Mcp4461Component::set_initial_value(Mcp4461WiperIdx wiper, float initial_value) { @@ -77,9 +105,12 @@ void Mcp4461Component::dump_config() { // so also invalid for nonvolatile. For these, only print current level. // reworked to be a one-line intentionally, as output would not be in order if (i < 4) { - ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i, - this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), - ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w)); + ESP_LOGCONFIG(TAG, + " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, " + "A: %s, B: %s, W: %s, NV: %s", + i, this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), + ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w), + ONOFF(this->reg_[i].nonvolatile)); } else { ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state); } @@ -92,8 +123,10 @@ void Mcp4461Component::loop() { } for (uint8_t i = 0; i < 8; i++) { if (this->reg_[i].update_level) { - // set wiper i state if changed - if (this->reg_[i].state != this->read_wiper_level_(i)) { + // set wiper i state if changed — a failed read (returns 0) must not suppress the + // write when the target state is 0, same hardening as the NV read-compare paths + bool read_ok = false; + if (this->reg_[i].state != this->read_wiper_level_(i, &read_ok) || !read_ok) { this->write_wiper_level_(i, this->reg_[i].state); } } @@ -112,6 +145,67 @@ void Mcp4461Component::loop() { } this->reg_[i].update_terminal = false; } + this->process_nonvolatile_dirty_(); +} + +void Mcp4461Component::process_nonvolatile_dirty_() { + const uint32_t now = millis(); + for (uint8_t i = 0; i < 4; i++) { + if (!this->reg_[i].nonvolatile || !this->reg_[i].nonvolatile_dirty) { + continue; + } + if ((now - this->reg_[i].last_level_change_ms) < this->reg_[i].nonvolatile_write_delay_ms) { + continue; // still settling — debounce window not over yet + } + // Never block the loop on a still-running EEPROM cycle (t_WC up to 10 ms); datasheet: + // during an EEPROM write only volatile commands are accepted. Retry on the next loop. + if (this->is_writing_()) { + continue; + } + // Clear the dirty flag on success — and equally when WP or WiperLock block the write + // permanently, instead of retrying forever. + if (this->store_level_nonvolatile_(static_cast(i)) || this->write_protected_ || + this->reg_[i].wiper_lock_active) { + this->reg_[i].nonvolatile_dirty = false; + } else { + // Transient failure (e.g. I2C error): without this, the retry fires on every single + // loop() iteration, spamming a warning each time. Re-arming the timestamp reuses the + // stability delay as a natural retry backoff. + this->reg_[i].last_level_change_ms = now; + } + } +} + +bool Mcp4461Component::store_level_nonvolatile_(Mcp4461WiperIdx wiper) { + if (this->is_failed()) { + ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_))); + return false; + } + uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + // E-H ARE the nonvolatile registers — keep this consistent with the other guards + // instead of failing silently (reachable via the store_nonvolatile action). + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } + if (this->reg_[wiper_idx].wiper_lock_active) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); + return false; + } + const uint16_t level = this->reg_[wiper_idx].state; + // Skip the EEPROM cycle entirely when the NV register already holds the value. A failed + // read must NOT count as a match (it returns 0): fall through to the write instead — if + // the bus is really down, the write fails too and the dirty flag stays set for a retry. + bool read_ok = false; + if (this->read_wiper_level_(wiper_idx + 4, &read_ok) == level && read_ok) { + return true; + } + ESP_LOGV(TAG, "Persisting wiper %u level %u to nonvolatile register", wiper_idx, level); + if (!this->mcp4461_write_(this->get_wiper_address_(wiper_idx + 4), level, true)) { + ESP_LOGW(TAG, "Error persisting wiper %u level %u", wiper_idx, level); + return false; + } + return true; } uint8_t Mcp4461Component::get_status_register_() { @@ -210,7 +304,10 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) { return this->read_wiper_level_(wiper_idx); } -uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { +uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx, bool *ok) { + if (ok != nullptr) { + *ok = false; + } uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t reg = addr | static_cast(Mcp4461Commands::READ); if (wiper_idx > 3) { @@ -225,6 +322,9 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); return 0; } + if (ok != nullptr) { + *ok = true; + } return buf; } @@ -265,6 +365,10 @@ bool Mcp4461Component::set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value) { ESP_LOGV(TAG, "Setting MCP4461 wiper %u to %u", wiper_idx, value); this->reg_[wiper_idx].state = value; this->reg_[wiper_idx].update_level = true; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } @@ -335,6 +439,12 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); return false; } + if (wiper_idx > 3) { + // Datasheet: increment commands are only valid for the volatile wiper registers — + // the chip NACKs them on nonvolatile addresses. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } if (this->reg_[wiper_idx].state == 256) { ESP_LOGV(TAG, "Maximum wiper level reached, further increase of wiper %u prohibited", wiper_idx); return false; @@ -342,13 +452,17 @@ 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(); return false; } this->reg_[wiper_idx].state++; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } @@ -366,6 +480,12 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); return false; } + if (wiper_idx > 3) { + // Datasheet: decrement commands are only valid for the volatile wiper registers — + // the chip NACKs them on nonvolatile addresses. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return false; + } if (this->reg_[wiper_idx].state == 0) { ESP_LOGV(TAG, "Minimum wiper level reached, further decrease of wiper %u prohibited", wiper_idx); return false; @@ -373,18 +493,25 @@ 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(); return false; } this->reg_[wiper_idx].state--; + if (this->reg_[wiper_idx].nonvolatile) { + this->reg_[wiper_idx].nonvolatile_dirty = true; + this->reg_[wiper_idx].last_level_change_ms = millis(); + } return true; } uint8_t Mcp4461Component::calc_terminal_connector_byte_(Mcp4461TerminalIdx terminal_connector) { - uint8_t i = static_cast(terminal_connector) <= 1 ? 0 : 2; + // TCON0 covers wipers 0/1 (A/B), TCON1 covers wipers 2/3 (C/D). The enum only holds + // 0 and 1, so the old `<= 1 ? 0 : 2` collapsed to always-0 and built TCON1 from + // channels A/B's flags — mirror the (correct) read path in update_terminal_register_(). + uint8_t i = static_cast(terminal_connector) == 0 ? 0 : 2; uint8_t new_value_byte = 0; new_value_byte += static_cast(this->reg_[i].terminal_b); new_value_byte += static_cast(this->reg_[i].terminal_w) << 1; @@ -471,6 +598,12 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) { return; } uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + // Terminal control only exists for the volatile wipers; loop() would otherwise emit + // an unrelated TCON write and silently drop the request. + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return; + } ESP_LOGV(TAG, "Enabling terminal %c of wiper %u", terminal, wiper_idx); switch (terminal) { case 'h': @@ -498,6 +631,10 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) { return; } uint8_t wiper_idx = static_cast(wiper); + if (wiper_idx > 3) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE))); + return; + } ESP_LOGV(TAG, "Disabling terminal %c of wiper %u", terminal, wiper_idx); switch (terminal) { case 'h': diff --git a/esphome/components/mcp4461/mcp4461.h b/esphome/components/mcp4461/mcp4461.h index 3a76f855b8..933d92c1fa 100644 --- a/esphome/components/mcp4461/mcp4461.h +++ b/esphome/components/mcp4461/mcp4461.h @@ -17,6 +17,16 @@ struct WiperState { bool wiper_lock_active = false; bool update_level = false; bool update_terminal = false; + // Nonvolatile persistence (volatile wipers 0-3 only): when enabled, every level change is + // mirrored into the chip's NV wiper register after nonvolatile_write_delay of stability, so + // the chip restores it on power-on. The delay both debounces bursts (e.g. light transitions + // writing dozens of levels per second) and protects the EEPROM's limited endurance — + // without it, every intermediate step would cost one of the ~1M erase/write cycles and + // stall the bus for up to t_WC (10 ms) each. + bool nonvolatile = false; + uint32_t nonvolatile_write_delay_ms = 1000; + bool nonvolatile_dirty = false; + uint32_t last_level_change_ms = 0; }; // default wiper state is 128 / 0x80h @@ -57,7 +67,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), @@ -86,6 +96,11 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { /// @param[in] wiper - the wiper to set the value for /// @param[in] initial_value - the initial value in range 0-1.0 as float void set_initial_value(Mcp4461WiperIdx wiper, float initial_value); + /// @brief enable nonvolatile persistence for a volatile wiper (0-3): every level change is + /// mirrored to the corresponding NV wiper register after the given stability delay + /// @param[in] wiper - the (volatile) wiper to persist + /// @param[in] write_delay_ms - stability delay before the NV write (debounce / EEPROM wear) + void set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms); /// @brief public function used to set disable terminal config /// @param[in] wiper - the wiper to set the value for /// @param[in] terminal - the terminal to disable, one of ['a','b','w','h'] @@ -98,7 +113,10 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { bool read_16_(uint8_t address, uint16_t *buf); void update_write_protection_status_(); uint8_t get_wiper_address_(uint8_t wiper); - uint16_t read_wiper_level_(uint8_t wiper); + /// Read a wiper register. On I2C failure returns 0 — callers that must distinguish + /// a real 0 from a failed read pass `ok` (added for the NV read-compare paths, where + /// acting on a failed read would skip a required write or drop a pending persist). + uint16_t read_wiper_level_(uint8_t wiper, bool *ok = nullptr); uint8_t get_status_register_(); uint16_t get_wiper_level_(Mcp4461WiperIdx wiper); bool set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value); @@ -110,6 +128,11 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { void enable_terminal_(Mcp4461WiperIdx wiper, char terminal); void disable_terminal_(Mcp4461WiperIdx, char terminal); bool is_writing_(); + /// Copy the current volatile level of wiper 0-3 into its NV register (immediate, blocking + /// only for a pending previous EEPROM cycle). Returns false while WP is active or on error. + bool store_level_nonvolatile_(Mcp4461WiperIdx wiper); + /// Deferred NV mirroring driven from loop() — see WiperState::nonvolatile. + void process_nonvolatile_dirty_(); bool is_eeprom_ready_for_writing_(bool wait_if_not_ready); void write_wiper_level_(uint8_t wiper, uint16_t value); bool mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolatile = false); @@ -139,6 +162,9 @@ class Mcp4461Component : public Component, public i2c::I2CDevice { return LOG_STR("MCP4461 Wiper is locked using WiperLock-technology. All actions on this wiper are prohibited."); case MCP4461_STATUS_OK: return LOG_STR("Status OK"); + case MCP4461_PROHIBITED_FOR_NONVOLATILE: + return LOG_STR( + "Increment/decrement, store, and terminal control are prohibited on the nonvolatile wipers (E-H)."); default: return LOG_STR("Unknown"); } diff --git a/esphome/components/mcp4461/output/__init__.py b/esphome/components/mcp4461/output/__init__.py index 0d145d81d3..db1a1e6a29 100644 --- a/esphome/components/mcp4461/output/__init__.py +++ b/esphome/components/mcp4461/output/__init__.py @@ -1,7 +1,11 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ID, CONF_INITIAL_VALUE +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType from .. import CONF_MCP4461_ID, Mcp4461Component, mcp4461_ns @@ -26,6 +30,42 @@ CHANNEL_OPTIONS = { CONF_TERMINAL_A = "terminal_a" CONF_TERMINAL_B = "terminal_b" CONF_TERMINAL_W = "terminal_w" +CONF_NONVOLATILE = "nonvolatile" +CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay" + +# Volatile wiper channels that have a nonvolatile shadow register on the chip +VOLATILE_CHANNELS = ("A", "B", "C", "D") + + +def _validate_nonvolatile(config: ConfigType) -> None: + channel = str(config[CONF_CHANNEL]) + + # Channels E-H address the nonvolatile registers directly — the mirroring options only + # make sense for the volatile channels A-D. + if channel not in VOLATILE_CHANNELS: + # Only reject what the user EXPLICITLY asked for and cannot have: enabling the + # mirroring or tuning its delay on E-H. An explicit `nonvolatile: false` is a + # harmless no-op and stays valid; bare configs (no key at all) must keep working. + # NOTE: FINAL_VALIDATE_SCHEMA intentionally mutates `config` in-place (uses setdefault) to apply defaults for callers. + if config.get(CONF_NONVOLATILE) or CONF_NONVOLATILE_WRITE_DELAY in config: + raise cv.Invalid( + f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the " + f"volatile channels A-D; channels E-H are the nonvolatile registers themselves" + ) + return + + config.setdefault(CONF_NONVOLATILE, True) + if config[CONF_NONVOLATILE]: + config.setdefault( + CONF_NONVOLATILE_WRITE_DELAY, + cv.positive_time_period_milliseconds("1s"), + ) + elif CONF_NONVOLATILE_WRITE_DELAY in config: + # Same consistency as the E-H rejection above: never silently ignore user input. + raise cv.Invalid( + f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'" + ) + CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -36,11 +76,23 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( cv.Optional(CONF_TERMINAL_B, default=True): cv.boolean, cv.Optional(CONF_TERMINAL_W, default=True): cv.boolean, cv.Optional(CONF_INITIAL_VALUE): cv.float_range(min=0.0, max=1.0), + # No schema defaults here: a default would materialize the keys on EVERY channel, + # making existing bare E-H configs fail final validation. The effective defaults + # (nonvolatile: true, delay 1s) are applied for the volatile channels A-D inside + # _validate_nonvolatile instead. Default-on rationale: the chip restores the + # nonvolatile wiper levels at power-on, so persisting every settled level change is + # the least surprising behavior — the pot simply comes back where it was. The write + # is deferred by nonvolatile_write_delay to debounce transitions and protect the + # EEPROM's endurance. + cv.Optional(CONF_NONVOLATILE): cv.boolean, + cv.Optional(CONF_NONVOLATILE_WRITE_DELAY): cv.positive_time_period_milliseconds, } ) +FINAL_VALIDATE_SCHEMA = _validate_nonvolatile -async def to_code(config): + +async def to_code(config: ConfigType) -> None: parent = await cg.get_variable(config[CONF_MCP4461_ID]) var = cg.new_Pvariable( config[CONF_ID], @@ -57,5 +109,86 @@ async def to_code(config): cg.add( parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE]) ) + if str(config[CONF_CHANNEL]) in VOLATILE_CHANNELS and config[CONF_NONVOLATILE]: + cg.add( + parent.set_nonvolatile( + config[CONF_CHANNEL], + config[CONF_NONVOLATILE_WRITE_DELAY], + ) + ) await output.register_output(var, config) await cg.register_parented(var, config[CONF_MCP4461_ID]) + + +# ---- Actions ---- +WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action) +WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action) +WiperStoreNonvolatileAction = mcp4461_ns.class_( + "WiperStoreNonvolatileAction", automation.Action +) +WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action) + +WIPER_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)} +) + +CONF_TERMINAL = "terminal" +CONF_ENABLE = "enable" + +TERMINAL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper), + cv.Required(CONF_TERMINAL): cv.one_of("a", "b", "w", "h", lower=True), + cv.Required(CONF_ENABLE): cv.boolean, + } +) + + +@automation.register_action( + "mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +) +@automation.register_action( + "mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True +) +async def mcp4461_wiper_step_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, wiper) + + +@automation.register_action( + "mcp4461.wiper.store_nonvolatile", + WiperStoreNonvolatileAction, + WIPER_ACTION_SCHEMA, + synchronous=True, +) +async def mcp4461_wiper_store_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, wiper) + + +@automation.register_action( + "mcp4461.wiper.set_terminal", + WiperSetTerminalAction, + TERMINAL_ACTION_SCHEMA, + synchronous=True, +) +async def mcp4461_wiper_terminal_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + wiper = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable( + action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE] + ) diff --git a/esphome/components/mcp4461/output/automation.h b/esphome/components/mcp4461/output/automation.h new file mode 100644 index 0000000000..4be317b2f8 --- /dev/null +++ b/esphome/components/mcp4461/output/automation.h @@ -0,0 +1,56 @@ +#pragma once + +#include "esphome/core/automation.h" +#include "mcp4461_output.h" + +namespace esphome::mcp4461 { + +template class WiperIncreaseAction : public Action { + public: + explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->increase_wiper(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +template class WiperDecreaseAction : public Action { + public: + explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->decrease_wiper(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +// Persist the current level to the chip's nonvolatile register immediately — useful with +// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to +// bypass the stability delay of the automatic mirroring. +template class WiperStoreNonvolatileAction : public Action { + public: + explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} + void play(Ts... x) override { this->wiper_->store_nonvolatile(); } + + protected: + Mcp4461Wiper *wiper_; +}; + +template class WiperSetTerminalAction : public Action { + public: + WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) + : wiper_(wiper), terminal_(terminal), enable_(enable) {} + void play(Ts... x) override { + if (this->enable_) { + this->wiper_->enable_terminal(this->terminal_); + } else { + this->wiper_->disable_terminal(this->terminal_); + } + } + + protected: + Mcp4461Wiper *wiper_; + char terminal_; + bool enable_; +}; + +} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 6912ad5f36..5c373ddc7d 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(); @@ -64,6 +66,12 @@ void Mcp4461Wiper::decrease_wiper() { } } +void Mcp4461Wiper::store_nonvolatile() { + if (this->parent_->store_level_nonvolatile_(this->wiper_)) { + ESP_LOGV(TAG, "Stored wiper %u level to nonvolatile register", static_cast(this->wiper_)); + } +} + void Mcp4461Wiper::enable_terminal(char terminal) { this->parent_->enable_terminal_(this->wiper_, terminal); } void Mcp4461Wiper::disable_terminal(char terminal) { this->parent_->disable_terminal_(this->wiper_, terminal); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 73eadceb50..c8d1ef1ec5 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 @@ -36,6 +36,9 @@ class Mcp4461Wiper : public output::FloatOutput, public Parentedwrite_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/mcp4725/output.py b/esphome/components/mcp4725/output.py index 5ec6a9d686..0c1224a1a7 100644 --- a/esphome/components/mcp4725/output.py +++ b/esphome/components/mcp4725/output.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, output import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -19,7 +20,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py index da3244be84..f48bdde681 100644 --- a/esphome/components/mcp4728/__init__.py +++ b/esphome/components/mcp4728/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] DEPENDENCIES = ["i2c"] @@ -24,7 +25,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_STORE_IN_EEPROM]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/__init__.py b/esphome/components/mcp4728/output/__init__.py index 6f4a41510f..e8cb4c47d6 100644 --- a/esphome/components/mcp4728/output/__init__.py +++ b/esphome/components/mcp4728/output/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import output import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_GAIN, CONF_ID +from esphome.types import ConfigType from .. import CONF_MCP4728_ID, MCP4728Component, mcp4728_ns @@ -50,7 +51,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: paren = await cg.get_variable(config[CONF_MCP4728_ID]) var = cg.new_Pvariable( config[CONF_ID], 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/mcp47a1/output.py b/esphome/components/mcp47a1/output.py index ebd597cfeb..91bb3b47db 100644 --- a/esphome/components/mcp47a1/output.py +++ b/esphome/components/mcp47a1/output.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c, output import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] @@ -20,7 +21,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 65ae5f2eec..5542ffaa6c 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -8,6 +8,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType CONF_HOT_JUNCTION = "hot_junction" CONF_COLD_JUNCTION = "cold_junction" @@ -62,7 +63,7 @@ FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) 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/mcp9808/sensor.py b/esphome/components/mcp9808/sensor.py index ba6718ca56..1daaa9c131 100644 --- a/esphome/components/mcp9808/sensor.py +++ b/esphome/components/mcp9808/sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType CODEOWNERS = ["@k7hpn"] DEPENDENCIES = ["i2c"] @@ -28,7 +29,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/md5/__init__.py b/esphome/components/md5/__init__.py index 1710b00e66..6a928d1682 100644 --- a/esphome/components/md5/__init__.py +++ b/esphome/components/md5/__init__.py @@ -1,11 +1,12 @@ import esphome.codegen as cg from esphome.core import CORE from esphome.helpers import IS_MACOS +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_define("USE_MD5") # Add OpenSSL library for host platform 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 2b25cf243d..c9334ea97a 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -31,7 +31,7 @@ MDNSTXTRecord = mdns_ns.struct("MDNSTXTRecord") MDNSService = mdns_ns.struct("MDNSService") -def _remove_id_if_disabled(value): +def _remove_id_if_disabled(value: ConfigType) -> ConfigType: value = value.copy() if value[CONF_DISABLED]: value.pop(CONF_ID) @@ -62,7 +62,7 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config -def _require_network_interface(config: ConfigType) -> ConfigType: +def _require_network_interface(config: ConfigType) -> None: """Require a network interface for mDNS on Arduino/LEAmDNS platforms. On ESP8266 and RP2040 the C++ implementation needs at least one IP state @@ -70,18 +70,17 @@ 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): - return config + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): + return 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( "mdns on this platform requires a network interface — " f"add a {options} component to your configuration." ) - return config CONFIG_SCHEMA = cv.All( @@ -118,7 +117,7 @@ def mdns_txt_record(key: str, value: str) -> cg.RawExpression: async def _mdns_txt_record_templated( - mdns_comp: cg.Pvariable, key: str, value: Lambda | str + mdns_comp: cg.MockObj, key: str, value: Lambda | str ) -> cg.RawExpression: """Create a mDNS TXT record with support for templated values. @@ -173,7 +172,7 @@ def mdns_service( ) -def enable_mdns_storage(): +def enable_mdns_storage() -> None: """Enable persistent storage of mDNS services in the MDNSComponent. Called by external components (like OpenThread) that need access to @@ -185,31 +184,31 @@ def enable_mdns_storage(): @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) -async def to_code(config): +async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: return 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() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.11.0") + add_idf_component(name="espressif/mdns", ref="1.11.3") cg.add_define("USE_MDNS") @@ -274,11 +273,12 @@ 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, PlatformFramework.LN882X_ARDUINO, }, + "mdns_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) 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_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index f6d5786675..1f0b3c9519 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -41,7 +41,19 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { +#ifdef USE_MDNS_WIFI_LISTENER + // MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is + // failing (radio off-channel during a roam scan, or mid reconnect); an incoming + // packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state. + // Skip the tick while the radio cannot transmit (#18760), but keep polling while + // the AP is serving clients (AP-only or fallback AP with the STA down). + auto *wifi = wifi::global_wifi_component; + if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active())) + return; +#endif + MDNS.update(); + }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } #endif 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_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a543a3809a..5354bd241c 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -27,8 +27,8 @@ static void register_libretiny(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 new file mode 100644 index 0000000000..e23f3ac027 --- /dev/null +++ b/esphome/components/mdns/mdns_zephyr.cpp @@ -0,0 +1,24 @@ +#include "esphome/core/defines.h" +#if defined(USE_ZEPHYR) && defined(USE_MDNS) + +#include "mdns_component.h" + +namespace esphome::mdns { + +#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() { 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() {} + +} // namespace esphome::mdns + +#endif // USE_ZEPHYR && USE_MDNS 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/media_source/__init__.py b/esphome/components/media_source/__init__.py index 43256db4af..c9dab7e4d2 100644 --- a/esphome/components/media_source/__init__.py +++ b/esphome/components/media_source/__init__.py @@ -3,7 +3,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] @@ -16,7 +17,7 @@ media_source_ns = cg.esphome_ns.namespace("media_source") MediaSource = media_source_ns.class_("MediaSource") -async def register_media_source(var, config): +async def register_media_source(var: MockObj, config: ConfigType) -> MockObj: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) CORE.register_platform_component("media_source", var) @@ -35,6 +36,6 @@ def media_source_schema( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(media_source_ns.using) cg.add_define("USE_MEDIA_SOURCE") 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/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index b7d0ad1998..33cb27080c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -15,6 +15,9 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_PARTS_PER_MILLION, ) +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType DEPENDENCIES = ["uart"] @@ -78,7 +81,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) @@ -129,7 +132,12 @@ NO_ARGS_ACTION_SCHEMA = maybe_simple_id( NO_ARGS_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_no_args_action_to_code(config, action_id, template_arg, args): +async def mhz19_no_args_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -151,7 +159,12 @@ RANGE_ACTION_SCHEMA = maybe_simple_id( RANGE_ACTION_SCHEMA, synchronous=True, ) -async def mhz19_detection_range_set_to_code(config, action_id, template_arg, args): +async def mhz19_detection_range_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) detection_range = config.get(CONF_DETECTION_RANGE) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 38926fce99..092c4977ce 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -2,12 +2,15 @@ import hashlib import json import logging from pathlib import Path +import re from urllib.parse import urljoin from esphome import automation, external_files, git from esphome.automation import register_action, register_condition +from esphome.bundle import add_bundle_file import esphome.codegen as cg -from esphome.components import esp32, microphone, ota +from esphome.components import esp32, microphone, ota, psram +from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, @@ -20,6 +23,7 @@ from esphome.const import ( CONF_RAW_DATA_ID, CONF_REF, CONF_REFRESH, + CONF_TASK_STACK_IN_PSRAM, CONF_TYPE, CONF_URL, CONF_USERNAME, @@ -27,6 +31,7 @@ from esphome.const import ( TYPE_LOCAL, ) from esphome.core import CORE, HexInt +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -161,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema( def _compute_local_file_path(config: dict) -> Path: - url = config[CONF_URL] - 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 + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -206,40 +206,62 @@ def _validate_manifest_version(manifest_data): raise cv.Invalid("Invalid manifest file, missing 'version' key") -def _process_http_source(config): - url = config[CONF_URL] - path = _compute_local_file_path(config) +HTTP_SCHEMA = cv.Schema( + { + # validate_url only accepts http(s); the shorthand validator relies + # on this branch rejecting git shorthands ("github://...") so they + # fall through to the git branch. + cv.Required(CONF_URL): validate_url, + } +) - json_path = path / "manifest.json" - json_contents = external_files.download_content(url, json_path) +def _register_local_model_file(config: ConfigType) -> ConfigType: + """Register the model file that the manifest points to, so bundles include it. - manifest_data = json.loads(json_contents) - if not isinstance(manifest_data, dict): - raise cv.Invalid("Manifest file must contain a JSON object") - - model = manifest_data[CONF_MODEL] - model_url = urljoin(url, model) - - model_path = path / model - - external_files.download_content(str(model_url), model_path) + The manifest names its model file relative to itself, so that path never appears + in the YAML and bundle discovery cannot find it on its own. + Problems with the manifest are logged and ignored here rather than raised. Loading + the manifest later reports them with better messages, and raising would be + swallowed by the shorthand validator, which then reports a confusing error about a + missing file in a git repository. Logging keeps the skipped registration + diagnosable if the manifest is only briefly unreadable, since the bundle would + then be built without the model file. + """ + manifest_path: Path = config[CONF_PATH] + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + model = manifest[CONF_MODEL] + except (OSError, ValueError, KeyError, TypeError) as err: + _LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err) + return config + if not isinstance(model, str): + _LOGGER.debug( + "Not registering a model file from %s: 'model' is %s, expected a string", + manifest_path, + type(model).__name__, + ) + return config + add_bundle_file(manifest_path.parent / model) return config -HTTP_SCHEMA = cv.All( - { - cv.Required(CONF_URL): cv.url, - }, - _process_http_source, +LOCAL_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), + } + ), + _register_local_model_file, ) -LOCAL_SCHEMA = cv.Schema( - { - cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_), - } -) + +# Bare model names in the official model repository ("okay_nabu"). Must not +# overlap with local paths, http(s) urls, or git shorthands +# ("github://user/repo/file.json@ref"), which the shorthand validator tries +# next; anything containing "/", ":" or "@" is not a model name. +_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+") def _validate_source_model_name(value): @@ -249,6 +271,9 @@ def _validate_source_model_name(value): if value.endswith(".json"): raise cv.Invalid("Model name must not end with .json") + if not _MODEL_NAME_RE.fullmatch(value): + raise cv.Invalid("Model name may only contain letters, numbers, . _ -") + return MODEL_SOURCE_SCHEMA( { CONF_TYPE: TYPE_HTTP, @@ -338,6 +363,61 @@ def _maybe_empty_vad_schema(value): return VAD_MODEL_SCHEMA(value) +def _download_http_models(config: ConfigType) -> ConfigType: + """Download every http-sourced manifest and model file in two concurrent + batches (all manifests, then all model files). + + The model file's URL only becomes known once its manifest has been + fetched and parsed, so the two stages cannot be merged into one batch. + """ + model_parameters = [*config[CONF_MODELS]] + if vad := config.get(CONF_VAD): + model_parameters.append(vad) + # Keyed by cache path so a URL referenced twice is fetched and parsed once + http_models: dict[Path, str] = { + _compute_local_file_path(model_config): model_config[CONF_URL] + for parameters in model_parameters + if (model_config := parameters.get(CONF_MODEL)) is not None + and model_config.get(CONF_TYPE) == TYPE_HTTP + } + if not http_models: + return config + + external_files.download_content_many( + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), + description="wake word manifest(s)", + ) + + model_files: list[external_files.RemoteFile] = [] + errors: list[cv.Invalid] = [] + for path, url in http_models.items(): + try: + manifest_data = json.loads((path / "manifest.json").read_bytes()) + except (OSError, ValueError) as e: + errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}")) + continue + if not isinstance(manifest_data, dict): + errors.append( + cv.Invalid(f"Manifest file at {url} must contain a JSON object") + ) + continue + model = manifest_data.get(CONF_MODEL) + if not isinstance(model, str): + errors.append( + cv.Invalid(f"Manifest file at {url} is missing the 'model' key") + ) + continue + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) + if errors: + raise cv.MultipleInvalid(errors) + + external_files.download_content_many(model_files, description="wake word model(s)") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -350,7 +430,7 @@ CONFIG_SCHEMA = cv.All( min_channels=1, max_channels=1, ), - cv.Required(CONF_MODELS): cv.ensure_list( + cv.Optional(CONF_MODELS, default=[]): cv.ensure_list( cv.maybe_simple_value(MODEL_SCHEMA, key=CONF_MODEL) ), cv.Optional(CONF_ON_WAKE_WORD_DETECTED): automation.validate_automation( @@ -358,6 +438,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." ), @@ -370,18 +451,19 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.only_on_esp32, + _download_http_models, ) def _load_model_data(manifest_path: Path): - with open(manifest_path, encoding="utf-8") as f: + with manifest_path.open(encoding="utf-8") as f: manifest = json.load(f) _validate_manifest_version(manifest) model_path = manifest_path.parent / manifest[CONF_MODEL] - with open(model_path, "rb") as f: + with model_path.open("rb") as f: model = f.read() if manifest.get(KEY_VERSION) == 1: @@ -451,6 +533,10 @@ async def to_code(config): cg.add_define("USE_MICRO_WAKE_WORD") ota.request_ota_state_listeners() + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() + esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1") # Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn) esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2") @@ -467,6 +553,9 @@ async def to_code(config): # Use the general model loading code for the VAD codegen config[CONF_MODELS].append(vad_model) + # Default feature step size for runtime models + feature_step_size = 10 + for i, model_parameters in enumerate(config[CONF_MODELS]): model_config = model_parameters.get(CONF_MODEL) data = [] @@ -485,6 +574,9 @@ async def to_code(config): manifest[KEY_MICRO][CONF_SLIDING_WINDOW_SIZE], ) + # Update feature step size from manifest + feature_step_size = manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE] + if manifest[KEY_WAKE_WORD] == "vad": cg.add( var.add_vad_model( @@ -514,7 +606,7 @@ async def to_code(config): cg.add(var.add_wake_word_model(wake_word_model)) - cg.add(var.set_features_step_size(manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE])) + cg.add(var.set_features_step_size(feature_step_size)) cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION])) if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED): 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.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6877e9e5df..3dadb78077 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -9,6 +9,8 @@ #include "esphome/components/audio/audio_transfer_buffer.h" +#include + #ifdef USE_OTA #include "esphome/components/ota/ota_backend.h" #endif @@ -33,22 +35,36 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072; static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { - COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_STOP = (1 << 0), // Signals the inference task should stop + COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio + COMMAND_PAUSE_MODELS = (1 << 2), // Asks the inference task to pause at a safe point so the model lists can be + // mutated from the main loop TASK_STARTING = (1 << 3), TASK_RUNNING = (1 << 4), TASK_STOPPING = (1 << 5), TASK_STOPPED = (1 << 6), + MODELS_PAUSED = (1 << 7), // Inference task acknowledges it is paused and holds no iterators + COMMAND_RESUME_MODELS = (1 << 8), // Main loop signals the inference task it may resume iterating + ERROR_MEMORY = (1 << 9), ERROR_INFERENCE = (1 << 10), WARNING_FULL_RING_BUFFER = (1 << 13), + WARNING_MODELS_RESUME_TIMEOUT = (1 << 14), // The paused inference task gave up waiting to be released ERROR_BITS = ERROR_MEMORY | ERROR_INFERENCE, ALL_BITS = 0xfffff, // 24 total bits available in an event group }; +// How long the main loop waits for the inference task to acknowledge a pause request before giving up. +// The task checks for the command at the top of its loop, which runs at least every DATA_TIMEOUT_MS. +static const uint32_t MODELS_PAUSE_TIMEOUT_MS = 500; +// How long the paused inference task waits to be resumed before rechecking on its own. Only reached if +// the main loop abandoned the handshake (e.g. it timed out first), so recovery just needs to be bounded. +static const uint32_t MODELS_RESUME_TIMEOUT_MS = 1000; + float MicroWakeWord::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } static const LogString *micro_wake_word_state_to_string(State state) { @@ -114,13 +130,13 @@ void MicroWakeWord::setup() { } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); if (this->ring_buffer_.use_count() > 1) { - size_t bytes_free = temp_ring_buffer->free(); - - if (bytes_free < data.size()) { - xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER); - temp_ring_buffer->reset(); + // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task + // to drain it - reset() is a consumer operation and must run on the inference task's thread. + // Disable partial writes so audio chunks are either fully accepted or rejected and handled below. + if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) { + xEventGroupSetBits(this->event_group_, + EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER); } - temp_ring_buffer->write((void *) data.data(), data.size()); } }); @@ -146,56 +162,79 @@ void MicroWakeWord::inference_task(void *params) { { // Ensures any C++ objects fall out of scope to deallocate before deleting the task - const size_t new_bytes_to_process = - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_); - std::unique_ptr audio_buffer; + const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info(); + const size_t bytes_per_frame = stream_info.frames_to_bytes(1); + const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_); + std::unique_ptr audio_source; int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]; if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate audio transfer buffer - audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process); - - if (audio_buffer == nullptr) { + // Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample. + const size_t ring_buffer_size = + (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; + std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); + if (temp_ring_buffer == nullptr) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes, + static_cast(bytes_per_frame)); + if (audio_source == nullptr) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); + } else { + this_mww->ring_buffer_ = temp_ring_buffer; + } } } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { - // Allocate ring buffer - std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create( - this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); - if (temp_ring_buffer.use_count() == 0) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY); - } - audio_buffer->set_source(temp_ring_buffer); - this_mww->ring_buffer_ = temp_ring_buffer; - } - if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) { this_mww->microphone_source_->start(); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); - while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) { - audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS)); - - if (audio_buffer->available() < new_bytes_to_process) { - // Insufficient data to generate new spectrogram features, read more next iteration + while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) { + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_PAUSE_MODELS) { + // Safe point: no iterators into wake_word_models_ are held here. Acknowledge the pause and wait for the + // main loop to finish mutating the model lists before resuming. + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::MODELS_PAUSED); + EventBits_t resume_bits = xEventGroupWaitBits(this_mww->event_group_, EventGroupBits::COMMAND_RESUME_MODELS, + pdTRUE, pdTRUE, pdMS_TO_TICKS(MODELS_RESUME_TIMEOUT_MS)); + if (!(resume_bits & EventGroupBits::COMMAND_RESUME_MODELS)) { + // Nobody released us, so the main loop abandoned the handshake and did not mutate the lists. + // Rechecking the pause command below is safe, but the wait cost a second of detection, so report it. + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT); + } continue; } - // Generate new spectrogram features - uint32_t processed_samples = this_mww->generate_features_( - (int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer); - audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t)); - - // Run inference using the new spectorgram features - if (!this_mww->update_model_probabilities_(features_buffer)) { - xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); - break; + if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) { + // Producer asked us to drain; run the consumer-side reset from this thread. + audio_source->clear_buffered_data(); + xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER); } - // Process each model's probabilities and possibly send a Detection Event to the queue - this_mww->process_probabilities_(); + audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false); + + // The frontend buffers samples internally and only emits a feature once it has a full window, so we can + // hand it whatever the source exposes. The frontend consumes at least one sample per call, so available() + // strictly decreases and this loop always terminates. + while (audio_source->available() >= sizeof(int16_t)) { + const size_t samples_available = audio_source->available() / sizeof(int16_t); + const int16_t *audio_data = reinterpret_cast(audio_source->data()); + + size_t processed_samples = 0; + const bool feature_generated = + this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples); + audio_source->consume(processed_samples * sizeof(int16_t)); + + if (feature_generated) { + if (!this_mww->update_model_probabilities_(features_buffer)) { + xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE); + break; + } + + // Process each model's probabilities and possibly send a Detection Event to the queue + this_mww->process_probabilities_(); + } + } } } } @@ -207,10 +246,7 @@ void MicroWakeWord::inference_task(void *params) { FrontendFreeStateContents(&this_mww->frontend_state_); xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED); - while (true) { - // Continuously delay until the main loop deletes the task - delay(10); - } + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } std::vector MicroWakeWord::get_wake_words() { @@ -225,6 +261,130 @@ std::vector MicroWakeWord::get_wake_words() { void MicroWakeWord::add_wake_word_model(WakeWordModel *model) { this->wake_word_models_.push_back(model); } +bool MicroWakeWord::try_lock_models_() { + // When the inference task isn't running it holds no iterators into wake_word_models_, so the lists can be + // mutated without a handshake. The main loop is the only caller, so this state cannot change between here + // and the matching unlock_models_() call. + if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) { + return true; + } + + // The task is running and iterates wake_word_models_. Ask it to pause at a safe point before we mutate. + // Clear any stale acknowledgement from an abandoned handshake first. + xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED); + xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS); + + EventBits_t bits = xEventGroupWaitBits(this->event_group_, EventGroupBits::MODELS_PAUSED, pdFALSE, pdTRUE, + pdMS_TO_TICKS(MODELS_PAUSE_TIMEOUT_MS)); + + if (!(bits & EventGroupBits::MODELS_PAUSED)) { + // The task never acknowledged (e.g. it is busy stopping). Withdraw the request and refuse to mutate a + // list it might be iterating. + xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS); + return false; + } + return true; +} + +void MicroWakeWord::unlock_models_() { + if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) { + return; // Nothing was paused + } + xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED | EventGroupBits::COMMAND_PAUSE_MODELS); + xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_RESUME_MODELS); +} + +bool MicroWakeWord::add_runtime_model(std::unique_ptr model) { + if (!model) { + ESP_LOGE(TAG, "Cannot add null runtime model"); + return false; + } + + const std::string model_id = model->get_id(); + + // A model without usable data can never load, so keep it out of the lists entirely. Otherwise it would be + // advertised to Home Assistant as selectable and the inference task would silently disable it again every + // time it was enabled. + if (!model->has_model_data()) { + ESP_LOGE(TAG, "Runtime model '%s' has no valid data", model_id.c_str()); + return false; + } + + // Reject a duplicate id against every model (compiled or runtime). The inference task only ever reads + // wake_word_models_, so scanning it here (on the main loop) needs no synchronization. + for (auto *existing : this->wake_word_models_) { + if (existing->get_id() == model_id) { + ESP_LOGW(TAG, "Wake word model '%s' already exists", model_id.c_str()); + return false; + } + } + + if (!this->try_lock_models_()) { + ESP_LOGE(TAG, "Timed out pausing inference task; not adding runtime model '%s'", model_id.c_str()); + return false; + } + + this->wake_word_models_.push_back(model.get()); + this->runtime_models_.push_back(std::move(model)); + + this->unlock_models_(); + ESP_LOGD(TAG, "Added runtime model '%s'", model_id.c_str()); + return true; +} + +bool MicroWakeWord::remove_runtime_model(const std::string &model_id) { + // Only runtime-downloaded models can be removed; compiled-in models never appear in runtime_models_. + auto runtime_it = + std::find_if(this->runtime_models_.begin(), this->runtime_models_.end(), + [&model_id](const std::unique_ptr &m) { return m->get_id() == model_id; }); + if (runtime_it == this->runtime_models_.end()) { + return false; + } + + if (!this->try_lock_models_()) { + ESP_LOGE(TAG, "Timed out pausing inference task; not removing runtime model '%s'", model_id.c_str()); + return false; + } + + WakeWordModel *raw = runtime_it->get(); + auto models_it = std::find(this->wake_word_models_.begin(), this->wake_word_models_.end(), raw); + if (models_it != this->wake_word_models_.end()) { + this->wake_word_models_.erase(models_it); + } + + // Queued detection events hold a pointer into the model being destroyed, so drop them. The inference task + // is parked, so no new events can be queued concurrently. Losing an undelivered detection from another + // model is acceptable for this rare operation. + xQueueReset(this->detection_queue_); + + // Free the interpreter and arenas (safe: the task is parked, not mid-inference), then destroy the model. + // Its ModelData releases the PSRAM model buffer once the last shared_ptr reference drops. + raw->unload_model(); + this->runtime_models_.erase(runtime_it); + + this->unlock_models_(); + ESP_LOGI(TAG, "Removed runtime model '%s'", model_id.c_str()); + return true; +} + +std::vector MicroWakeWord::get_runtime_model_ids() { + std::vector ids; + ids.reserve(this->runtime_models_.size()); + for (const auto &model : this->runtime_models_) { + ids.push_back(model->get_id()); + } + return ids; +} + +WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { + for (auto *model : this->wake_word_models_) { + if (model->get_id() == model_id) { + return model; + } + } + return nullptr; +} + #ifdef USE_MICRO_WAKE_WORD_VAD void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size) { @@ -233,14 +393,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit #endif void MicroWakeWord::suspend_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskSuspend(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskSuspend(this->inference_task_.get_handle()); } } void MicroWakeWord::resume_task_() { - if (this->inference_task_handle_ != nullptr) { - vTaskResume(this->inference_task_handle_); + if (this->inference_task_.is_created()) { + vTaskResume(this->inference_task_.get_handle()); } } @@ -263,6 +423,12 @@ void MicroWakeWord::loop() { "word detection accuracy will temporarily be reduced."); } + if (event_group_bits & EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT) { + xEventGroupClearBits(this->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT); + ESP_LOGW(TAG, "Inference task paused for %" PRIu32 " ms without being released, so it resumed on its own", + MODELS_RESUME_TIMEOUT_MS); + } + if (event_group_bits & EventGroupBits::TASK_STARTING) { ESP_LOGD(TAG, "Inference task has started, attempting to allocate memory for buffers"); xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STARTING); @@ -282,8 +448,7 @@ void MicroWakeWord::loop() { if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - vTaskDelete(this->inference_task_handle_); - this->inference_task_handle_ = nullptr; + this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); @@ -301,7 +466,7 @@ void MicroWakeWord::loop() { switch (this->state_) { case State::STARTING: - if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) { + if (!this->inference_task_.is_created() && !this->status_has_error()) { // Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, @@ -310,10 +475,8 @@ void MicroWakeWord::loop() { return; } - xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, - INFERENCE_TASK_PRIORITY, &this->inference_task_handle_); - - if (this->inference_task_handle_ == nullptr) { + if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error("task_start", 1000); } @@ -386,11 +549,15 @@ void MicroWakeWord::set_state_(State state) { } } -size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) { - size_t processed_samples = 0; +bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) { + *processed_samples = 0; struct FrontendOutput frontend_output = - FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples); + FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples); + + if (frontend_output.size == 0) { + return false; + } for (size_t i = 0; i < frontend_output.size; ++i) { // These scaling values are set to match the TFLite audio frontend int8 output. @@ -415,7 +582,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a features_buffer[i] = static_cast(clamp(value, INT8_MIN, INT8_MAX)); } - return processed_samples; + return true; } void MicroWakeWord::process_probabilities_() { diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 5c0c056ac0..03f4a86fd4 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -11,6 +11,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/static_task.h" #ifdef USE_OTA_STATE_LISTENER #include "esphome/components/ota/ota_backend.h" @@ -30,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: @@ -59,10 +60,38 @@ class MicroWakeWord : public Component void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + Trigger *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; } void add_wake_word_model(WakeWordModel *model); + /// @brief Adds a runtime-downloaded wake word model. Must be called from the main loop. + /// If the inference task is running it is paused at a safe point before the model lists are mutated, + /// so the task never observes a half-updated vector. + /// Callers should check get_model_by_id() before constructing the model: a WakeWordModel permanently + /// claims a preference backend that is not released when the model is destroyed, so building one only to + /// have it rejected here costs internal RAM that never comes back. + /// @return True if the model was added, false if it has no valid data, on a duplicate id, or if the task + /// could not be paused + bool add_runtime_model(std::unique_ptr model); + + /// @brief Removes a runtime-downloaded wake word model and frees its interpreter, arenas, and model buffer. + /// Must be called from the main loop. If the inference task is running it is paused at a safe point first, + /// and any queued detection events are dropped (they hold pointers into the model being destroyed). + /// @return True if the model was removed, false if the id is not a runtime model or the task could not be paused + bool remove_runtime_model(const std::string &model_id); + + /// @brief Returns the wake word model with the given id, or nullptr if none matches (compiled or runtime). + /// Must be called from the main loop, as the returned pointer is invalidated by remove_runtime_model(). + WakeWordModel *get_model_by_id(const std::string &model_id); + + /// @brief Returns the ids of all runtime-downloaded models. Must be called from the main loop. + std::vector get_runtime_model_ids(); + + /// @brief Returns the feature step size (ms) the frontend is configured for. Runtime models must match it. + uint8_t get_features_step_size() const { return this->features_step_size_; } + #ifdef USE_MICRO_WAKE_WORD_VAD void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size); @@ -82,6 +111,7 @@ class MicroWakeWord : public Component std::weak_ptr ring_buffer_; std::vector wake_word_models_; + std::vector> runtime_models_; #ifdef USE_MICRO_WAKE_WORD_VAD std::unique_ptr vad_model_; @@ -93,6 +123,8 @@ class MicroWakeWord : public Component bool stop_after_detection_; + bool task_stack_in_psram_{false}; + uint8_t features_step_size_; // Audio frontend handles generating spectrogram features @@ -105,23 +137,34 @@ class MicroWakeWord : public Component // Used to send messages about the models' states to the main loop QueueHandle_t detection_queue_; + StaticTask inference_task_; + static void inference_task(void *params); - TaskHandle_t inference_task_handle_{nullptr}; /// @brief Suspends the inference task void suspend_task_(); /// @brief Resumes the inference task void resume_task_(); + /// @brief Parks the inference task at a safe point (or verifies it isn't running) so the model lists may be + /// mutated from the main loop. Every successful call must be paired with unlock_models_(). + /// @return True if the lists may be mutated, false if the running task never acknowledged the pause request + bool try_lock_models_(); + /// @brief Releases the inference task parked by a successful try_lock_models_() call + void unlock_models_(); + void set_state_(State state); - /// @brief Generates spectrogram features from an input buffer of audio samples - /// @param audio_buffer (int16_t *) Buffer containing input audio samples - /// @param samples_available (size_t) Number of samples avaiable in the input buffer - /// @param features_buffer (int8_t *) Buffer to store generated features - /// @return (size_t) Number of samples processed from the input buffer - size_t generate_features_(int16_t *audio_buffer, size_t samples_available, - int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]); + /// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples + /// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have + /// accumulated to fill a full analysis window. + /// @param audio_buffer (const int16_t *) Buffer containing input audio samples + /// @param samples_available (size_t) Number of samples available in the input buffer + /// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true + /// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer + /// @return True if a new feature was generated; false if more samples are required + bool generate_features_(const int16_t *audio_buffer, size_t samples_available, + int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples); /// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent /// to the detection_queue_. diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp new file mode 100644 index 0000000000..a7326ab77a --- /dev/null +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -0,0 +1,100 @@ +#include "model_data.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::micro_wake_word { + +static const char *const TAG = "micro_wake_word"; + +ModelData::~ModelData() { this->deallocate_(); } + +bool ModelData::allocate(size_t size) { + // Reject up front: reallocating to zero frees the buffer and returns null, which would leave data_ pointing at + // freed memory. A zero-length model is never usable anyway. + if (size == 0) { + ESP_LOGE(TAG, "Refusing to allocate a zero-length model"); + return false; + } + + // Already allocated, so reallocate to the new size + if (this->data_) { + uint8_t *new_allocation = this->allocator_.reallocate(this->data_, size); + if (new_allocation == nullptr) { + ESP_LOGE(TAG, "Failed to reallocate %zu bytes", size); + return false; + } + this->data_ = new_allocation; + this->size_ = size; + this->valid_ = false; // Need to revalidate with new data + return true; + } + + // Try to allocate in PSRAM first + this->data_ = this->allocator_.allocate(size); + if (this->data_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes", size); + return false; + } + + this->size_ = size; + this->valid_ = false; + return true; +} + +void ModelData::deallocate_() { + if (this->data_ != nullptr) { + this->allocator_.deallocate(this->data_, this->size_); + this->data_ = nullptr; + this->size_ = 0; + this->valid_ = false; + } +} + +const uint8_t *ModelData::get_model_pointer() const { return this->valid_ ? this->data_ : nullptr; } + +uint8_t *ModelData::get_write_pointer() { + this->valid_ = false; // Mark invalid while writing + return this->data_; +} + +bool ModelData::validate_and_mark_ready() { + // The magic number lives in bytes 4-7, so we need at least 8 bytes to read it. + if (!this->data_ || this->size_ < 8) { + ESP_LOGE(TAG, "Model data is null or too small"); + return false; + } + + // Check TFLite magic number "TFL3" in bytes 4-7 + if (memcmp(this->data_ + 4, "TFL3", 4) != 0) { + ESP_LOGE(TAG, "Invalid TFLite model magic number"); + return false; + } + + // Bytes 0-3 hold the offset of the root table. tflite::GetModel only adds that offset to the start of the + // buffer, so check it lands inside the buffer before reading through it. + uint32_t root_offset; + memcpy(&root_offset, this->data_, sizeof(root_offset)); + if (root_offset >= this->size_) { + ESP_LOGE(TAG, "TFLite model root offset is out of bounds"); + return false; + } + + const tflite::Model *model = tflite::GetModel(this->data_); + if (model->version() != TFLITE_SCHEMA_VERSION) { + ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %d)", TFLITE_SCHEMA_VERSION, model->version()); + return false; + } + + this->valid_ = true; + return true; +} + +} // namespace esphome::micro_wake_word + +#endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/model_data.h b/esphome/components/micro_wake_word/model_data.h new file mode 100644 index 0000000000..0f0e08c718 --- /dev/null +++ b/esphome/components/micro_wake_word/model_data.h @@ -0,0 +1,60 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/core/helpers.h" + +namespace esphome::micro_wake_word { + +// Owns the buffer holding a runtime-downloaded TFLite model. The buffer prefers PSRAM but falls back to +// internal RAM, so a device without PSRAM can still hold a single model. It is filled over HTTP, checked +// for integrity by the caller (SHA256) and for a usable TFLite header here, then kept alive for the +// lifetime of the WakeWordModel that uses it. Only ever held behind a std::shared_ptr, so copies and +// moves are disabled. +class ModelData { + public: + ModelData() = default; + ~ModelData(); + + // Non-copyable, non-movable + ModelData(const ModelData &) = delete; + ModelData &operator=(const ModelData &) = delete; + ModelData(ModelData &&) = delete; + ModelData &operator=(ModelData &&) = delete; + + // Allocate memory for model + bool allocate(size_t size); + + // Get stable pointer for TFLite (only valid after validate_and_mark_ready()) + const uint8_t *get_model_pointer() const; + + // Get writable pointer for downloading (invalidates the model) + uint8_t *get_write_pointer(); + + // Validate TFLite model and mark as ready for use + bool validate_and_mark_ready(); + + // Check if model is valid and ready for use + bool is_valid() const { return this->valid_; } + + // Get size of model data + size_t size() const { return this->size_; } + + // Check if memory is allocated + bool is_allocated() const { return this->data_ != nullptr; } + + protected: + // Deallocate memory + void deallocate_(); + + uint8_t *data_{nullptr}; + size_t size_{0}; + bool valid_{false}; + RAMAllocator allocator_{RAMAllocator::NONE}; +}; + +} // namespace esphome::micro_wake_word + +#endif // USE_ESP32 diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 1cdc06b352..72984f04fb 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -26,6 +26,11 @@ void VADModel::log_model_config() { } bool StreamingModel::load_model_() { + if (this->model_start_ == nullptr) { + ESP_LOGE(TAG, "Streaming model has no data to load"); + return false; + } + RAMAllocator arena_allocator; if (this->var_arena_ == nullptr) { @@ -188,6 +193,13 @@ void StreamingModel::unload_model() { } bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCESSOR_FEATURE_SIZE]) { + if (this->model_start_ == nullptr) { + // No usable model data, and that cannot change for this object. Skip the model instead of reporting a + // failure, because a false return here stops the inference task for every other model too. + this->enabled_ = false; + return true; + } + if (this->enabled_ && !this->loaded_) { // Model is enabled but isn't loaded if (!this->load_model_()) { @@ -269,6 +281,41 @@ WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, } }; +WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr model_data, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, std::vector trained_languages, + size_t tensor_arena_size) { + this->id_ = id; + this->model_data_ = std::move(model_data); + // Callers are expected to pass a validated buffer, so this is normally the stable model pointer. Tolerate a + // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is + // never loaded. + this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; + if (this->model_start_ == nullptr) { + ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); + } + this->default_probability_cutoff_ = default_probability_cutoff; + this->probability_cutoff_ = default_probability_cutoff; + this->sliding_window_size_ = sliding_window_average_size; + this->recent_streaming_probabilities_.resize(sliding_window_average_size, 0); + this->wake_word_ = wake_word; + this->trained_languages_ = std::move(trained_languages); + this->tensor_arena_size_ = tensor_arena_size; + this->register_streaming_ops_(this->streaming_op_resolver_); + this->current_stride_step_ = 0; + this->internal_only_ = false; // Runtime models are always exposed to Home Assistant + + this->pref_ = global_preferences->make_preference(fnv1_hash(id)); + bool enabled; + if (this->pref_.load(&enabled)) { + // Use the enabled state loaded from flash + this->enabled_ = enabled; + } else { + // No saved state: stay disabled. The activation flow calls enable() explicitly after adding. + this->enabled_ = false; + } +}; + void WakeWordModel::enable() { this->enabled_ = true; if (!this->internal_only_) { diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 07ba78d1f4..1cb9d6eba5 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -3,9 +3,11 @@ #ifdef USE_ESP32 #include "preprocessor_settings.h" +#include "model_data.h" #include "esphome/core/preferences.h" +#include #include #include #include @@ -27,6 +29,10 @@ struct DetectionEvent { class StreamingModel { public: + // Runtime models are heap owned and destroyed while the device is running, so freeing the arenas cannot + // depend on the owner calling unload_model() first. unload_model() is not virtual and is safe to repeat. + virtual ~StreamingModel() { this->unload_model(); } + virtual void log_model_config() = 0; virtual DetectionEvent determine_detected() = 0; @@ -51,6 +57,9 @@ class StreamingModel { /// @brief Return true if the model is enabled. bool is_enabled() const { return this->enabled_; } + /// @brief Return true if the model has usable data. A model without it can never be loaded or run. + bool has_model_data() const { return this->model_start_ != nullptr; } + bool get_unprocessed_probability_status() const { return this->unprocessed_probability_status_; } // Quantized probability cutoffs mapping 0.0 - 1.0 to 0 - 255 @@ -86,7 +95,7 @@ class StreamingModel { size_t tensor_arena_size_; std::vector recent_streaming_probabilities_; - const uint8_t *model_start_; + const uint8_t *model_start_{nullptr}; uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -96,7 +105,7 @@ class StreamingModel { class WakeWordModel final : public StreamingModel { public: - /// @brief Constructs a wake word model object + /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said @@ -110,6 +119,23 @@ class WakeWordModel final : public StreamingModel { size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, bool internal_only); + /// @brief Constructs a wake word model object with a runtime-downloaded model + /// @param id (std::string) identifier for this model + /// @param model_data (std::shared_ptr) owning handle to the downloaded model buffer; must be valid + /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said + /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling + /// probability + /// @param wake_word (std::string) Friendly name of the wake word + /// @param trained_languages (std::vector) Languages the model was trained on + /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena + WakeWordModel(const std::string &id, std::shared_ptr model_data, uint8_t default_probability_cutoff, + size_t sliding_window_average_size, const std::string &wake_word, + std::vector trained_languages, size_t tensor_arena_size); + + // model_data_ is a member of this class, so it is destroyed before ~StreamingModel() runs. Unload here, while + // the buffer is still alive, so the interpreter is never torn down over freed model data. + ~WakeWordModel() override { this->unload_model(); } + void log_model_config() override; /// @brief Checks for the wake word by comparing the mean probability in the sliding window with the probability @@ -132,6 +158,10 @@ class WakeWordModel final : public StreamingModel { bool get_internal_only() { return this->internal_only_; } protected: + // Kept for runtime-downloaded models so the model buffer stays alive for the model's lifetime. + // Null for compiled-in models (their data lives in flash). + std::shared_ptr model_data_; + std::string id_; std::string wake_word_; std::vector trained_languages_; diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index b462352229..ff06d0b913 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -7,6 +7,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@jorre05", "@edenhaus"] @@ -63,7 +65,7 @@ def MICRONOVA_ADDRESS_SCHEMA( default_memory_location: int | None = None, default_memory_address: int | None = None, is_polling_component: bool, -): +) -> cv.Schema: location_key = ( cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) if default_memory_location is not None @@ -91,7 +93,9 @@ def register_micronova_writer() -> None: _get_data().has_writer = True -async def to_code_micronova_listener(mv, var, config): +async def to_code_micronova_listener( + mv: MockObj, var: MockObj, config: ConfigType +) -> None: _get_data().listener_count += 1 await cg.register_component(var, config) cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) @@ -100,7 +104,7 @@ async def to_code_micronova_listener(mv, var, config): cg.add(mv.register_micronova_listener(var)) -async def to_code(config): +async def to_code(config: ConfigType) -> None: enable_rx_pin = await cg.gpio_pin_expression(config[CONF_ENABLE_RX_PIN]) var = cg.new_Pvariable(config[CONF_ID], enable_rx_pin) await cg.register_component(var, config) diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 63b127e63d..68b5b9aca6 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import button import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MEMORY_ADDRESS, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if custom_button_config := config.get(CONF_CUSTOM_BUTTON): 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/__init__.py b/esphome/components/micronova/number/__init__.py index bcc972c5a9..d33bb150ce 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import CONF_STEP, DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -56,7 +57,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if thermostat_temperature_config := config.get(CONF_THERMOSTAT_TEMPERATURE): 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/__init__.py b/esphome/components/micronova/sensor/__init__.py index e53c49aca5..6091718d65 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_REVOLUTIONS_PER_MINUTE, ) +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -125,7 +126,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) for key, divisor in { 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/__init__.py b/esphome/components/micronova/switch/__init__.py index e149ee3ce3..1f57497ad7 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import switch import esphome.config_validation as cv from esphome.const import ICON_POWER +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -49,7 +50,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_config := config.get(CONF_STOVE): 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/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 33d0779eae..d6b94c437f 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv +from esphome.types import ConfigType from .. import ( CONF_MICRONOVA_ID, @@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) if stove_state_config := config.get(CONF_STOVE_STATE): 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/__init__.py b/esphome/components/microphone/__init__.py index 6b5ee8c3e1..9a3f5b43e7 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -1,3 +1,5 @@ +from collections.abc import Callable + from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg @@ -12,8 +14,10 @@ from esphome.const import ( CONF_ON_DATA, CONF_TRIGGER_ID, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.coroutine import CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] @@ -50,7 +54,7 @@ IsCapturingCondition = microphone_ns.class_( IsMutedCondition = microphone_ns.class_("IsMutedCondition", automation.Condition) -async def setup_microphone_core_(var, config): +async def setup_microphone_core_(var: MockObj, config: ConfigType) -> None: for conf in config.get(CONF_ON_DATA, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation( @@ -60,7 +64,7 @@ async def setup_microphone_core_(var, config): ) -async def register_microphone(var, config): +async def register_microphone(var: MockObj, config: ConfigType) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) await setup_microphone_core_(var, config) @@ -85,7 +89,7 @@ def microphone_source_schema( max_bits_per_sample: int = 16, min_channels: int = 1, max_channels: int = 1, -): +) -> cv.All: """Schema for a microphone source Components requesting microphone data should use this schema instead of accessing a microphone directly. @@ -97,7 +101,7 @@ def microphone_source_schema( max_channels (int, optional): Maximum number of channels the requesting component supports. Defaults to 1. """ - def _validate_unique_channels(config): + def _validate_unique_channels(config: list[int]) -> list[int]: if len(config) != len(set(config)): raise cv.Invalid("Channels must be unique") return config @@ -124,7 +128,7 @@ def microphone_source_schema( def final_validate_microphone_source_schema( component_name: str, sample_rate: int = cv.UNDEFINED -): +) -> Callable[[ConfigType], ConfigType]: """Validates that the microphone source can provide audio in the correct format. In particular it validates the sample rate and the enabled channels. Note that: @@ -136,7 +140,7 @@ def final_validate_microphone_source_schema( sample_rate (int, optional): The sample rate the component requesting mic audio requires """ - def _validate_audio_compatability(config): + def _validate_audio_compatability(config: ConfigType) -> ConfigType: if sample_rate is not cv.UNDEFINED: # Issues require changing the microphone configuration # - Verifies sample rates match @@ -161,7 +165,9 @@ def final_validate_microphone_source_schema( return _validate_audio_compatability -async def microphone_source_to_code(config, passive=False): +async def microphone_source_to_code( + config: ConfigType, passive: bool = False +) -> MockObj: """Creates a MicrophoneSource variable for codegen. Setting passive to true makes the MicrophoneSource never start/stop the microphone, but only receives audio when another component has actively started the Microphone. If false, then the microphone needs to be explicitly started/stopped. @@ -183,7 +189,12 @@ async def microphone_source_to_code(config, passive=False): return mic_source -async def microphone_action(config, action_id, template_arg, args): +async def microphone_action( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var @@ -219,6 +230,6 @@ automation.register_condition( @coroutine_with_priority(CoroPriority.CORE) -async def to_code(config): +async def to_code(config: ConfigType) -> None: cg.add_global(microphone_ns.using) cg.add_define("USE_MICROPHONE") 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/mics_4514/sensor.py b/esphome/components/mics_4514/sensor.py index 09329ebfcf..3ba560c781 100644 --- a/esphome/components/mics_4514/sensor.py +++ b/esphome/components/mics_4514/sensor.py @@ -14,6 +14,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_PARTS_PER_MILLION, ) +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] @@ -56,7 +57,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index ec9dc10297..30771d25ca 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/log.h" #include "ac_adapter.h" @@ -6,9 +6,9 @@ namespace esphome::midea::ac { const char *const Constants::TAG = "midea"; -const char *const Constants::FREEZE_PROTECTION = "freeze protection"; -const char *const Constants::SILENT = "silent"; -const char *const Constants::TURBO = "turbo"; +const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; +const char *const Constants::SILENT = "Silent"; +const char *const Constants::TURBO = "Turbo"; ClimateMode Converters::to_climate_mode(MideaMode mode) { switch (mode) { @@ -172,4 +172,4 @@ void Converters::to_climate_traits(ClimateTraits &traits, const dudanov::midea:: } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index a7924ae51e..4a888ee8ff 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -44,4 +44,4 @@ class Converters { } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/ac_automations.h b/esphome/components/midea/ac_automations.h index acd9191916..9572ec6c65 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/automation.h" #include "air_conditioner.h" @@ -63,4 +63,4 @@ template class PowerToggleAction : public MideaActionBase } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 594f7fa661..24bbfe76b0 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() { void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" - " [x] Period: %dms\n" - " [x] Response timeout: %dms\n" + " [x] Period: %" PRIu32 "ms\n" + " [x] Response timeout: %" PRIu32 "ms\n" " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER @@ -197,4 +197,4 @@ void AirConditioner::do_display_toggle() { } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 6ed5a82ff5..9977d2088f 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -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; } @@ -61,4 +61,4 @@ class AirConditioner : public ApplianceBase, } // namespace esphome::midea::ac -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/appliance_base.h b/esphome/components/midea/appliance_base.h index d36f5a322c..bce433394b 100644 --- a/esphome/components/midea/appliance_base.h +++ b/esphome/components/midea/appliance_base.h @@ -1,10 +1,11 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include #include +#include // Include global defines #include "esphome/core/defines.h" @@ -17,6 +18,13 @@ namespace esphome::midea { +// Mirrors the ARDUINO switch in MideaUART Helpers/Platform.h: these types +// exist in the dudanov namespace exactly when the library is not on Arduino +#ifndef ARDUINO +using dudanov::Stream; +using dudanov::String; +#endif + /* Stream from UART component */ class UARTStream : public Stream { public: @@ -99,4 +107,4 @@ template class ApplianceBase : public Component { } // namespace esphome::midea -#endif // USE_ARDUINO +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea/climate.py b/esphome/components/midea/climate.py index c954b45033..0e03bca233 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -25,6 +25,8 @@ from esphome.const import ( ICON_POWER, ICON_THERMOMETER, ICON_WATER_PERCENT, + PLATFORM_ESP32, + PLATFORM_ESP8266, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, UNIT_PERCENT, @@ -151,7 +153,12 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_with_arduino, + cv.only_on( + [ + PLATFORM_ESP32, + PLATFORM_ESP8266, + ] + ), ) # Actions @@ -260,6 +267,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) @@ -292,7 +304,14 @@ async def to_code(config): if CONF_HUMIDITY_SETPOINT in config: sens = await sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) cg.add(var.set_humidity_setpoint_sensor(sens)) - # MideaUART library requires WiFi (WiFi auto-enables Network via dependency mapping) - if CORE.is_esp32: + # MideaUART uses the Arduino WiFi API for the network-notify frame + # (WiFi auto-enables Network via dependency mapping). On ESP-IDF the + # library talks to esp_wifi directly, so no library entry is needed. + if CORE.is_esp32 and CORE.using_arduino: cg.add_library("WiFi", None) - cg.add_library("dudanov/MideaUART", "1.1.9") + # Using the repository until a release containing ESP-IDF support is published + cg.add_library( + name="MideaUART", + version=None, + repository="https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815", + ) diff --git a/esphome/components/midea/ir_transmitter.h b/esphome/components/midea/ir_transmitter.h index f11682230d..e54df1fd70 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #ifdef USE_REMOTE_TRANSMITTER #include "esphome/components/remote_base/midea_protocol.h" @@ -85,5 +85,5 @@ class IrTransmitter { } // namespace esphome::midea -#endif -#endif // USE_ARDUINO +#endif // USE_REMOTE_TRANSMITTER +#endif // USE_ARDUINO || USE_ESP_IDF diff --git a/esphome/components/midea_ir/climate.py b/esphome/components/midea_ir/climate.py index cbf5fae6fe..84bfeab0d4 100644 --- a/esphome/components/midea_ir/climate.py +++ b/esphome/components/midea_ir/climate.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import climate_ir import esphome.config_validation as cv from esphome.const import CONF_USE_FAHRENHEIT +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir", "coolix"] CODEOWNERS = ["@dudanov"] @@ -17,6 +18,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MideaIR).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) 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 ccd43c72cf..3f73f96327 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -2,8 +2,12 @@ # Various configuration constants for MIPI displays # Various utility functions for MIPI DBI configuration +from collections.abc import Callable +import functools from typing import Any, Self +import voluptuous as vol + from esphome.components.const import CONF_COLOR_DEPTH from esphome.components.display import CONF_SHOW_TEST_CARD, display_ns import esphome.config_validation as cv @@ -18,18 +22,26 @@ from esphome.const import ( CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, + CONF_MODEL, 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 @@ -114,6 +126,7 @@ CSCON = 0xF0 PWCTR6 = 0xF6 ADJCTL3 = 0xF7 PAGESEL = 0xFE +PAGESEL1 = 0xFF MADCTL_MY = 0x80 # Bit 7 Bottom to top MADCTL_MX = 0x40 # Bit 6 Right to left @@ -133,6 +146,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay +CONF_PAD_HEIGHT = "pad_height" +CONF_PAD_WIDTH = "pad_width" CONF_PIXEL_MODE = "pixel_mode" CONF_USE_AXIS_FLIPS = "use_axis_flips" @@ -196,6 +211,8 @@ def dimension_schema(rounding): rounding ), cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding), + cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding), + cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding), } ), ) @@ -239,11 +256,60 @@ def delay(ms): return DELAY_FLAG, ms +# Generic placeholder model present in every DriverChip registry; skipped when +# choosing a representative model for schema extraction. +_CUSTOM_MODEL = "CUSTOM" + + +def model_schema_extractor( + models: dict[str, Any], + model_schema: Callable[[dict[str, Any]], Any], + extra: dict[str, Any] | None = None, +) -> Callable[[Callable[[Any], Any]], Callable[[Any], Any]]: + """ + Decorate a model-driven display CONFIG_SCHEMA so the language-schema dumper + can extract it. + + The schema is generated per ``model`` at validation time, so the static + dumper has nothing to walk. When the dumper passes SCHEMA_EXTRACT, resolve a + representative schema for a real model (the generic "CUSTOM" placeholder + over-constrains fields like init_sequence) plus any *extra* keys the model + needs, e.g. a bus mode, and hand that back; runtime validation is untouched. + """ + + def decorate(config_schema: Callable[[Any], Any]) -> Callable[[Any], Any]: + @schema_extractor("schema") + @functools.wraps(config_schema) + def wrapper(config: Any) -> Any: + if config is not SCHEMA_EXTRACT: + return config_schema(config) + names = sorted(models) + representative = next((n for n in names if n != _CUSTOM_MODEL), names[0]) + schema = model_schema({CONF_MODEL: representative, **(extra or {})}) + if isinstance(schema, vol.All): + schema = next( + (v for v in schema.validators if isinstance(v, vol.Schema)), + schema, + ) + if isinstance(schema, vol.Schema): + # The resolved schema pins ``model`` to the representative; expose + # the full model list so the dumped enum offers every model. + schema = schema.extend( + {cv.Required(CONF_MODEL): cv.one_of(*names, upper=True)} + ) + return schema + + return wrapper + + return decorate + + 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] = {} @@ -257,6 +323,41 @@ class DriverChip: name = name.upper() self.name = name self.initsequence = initsequence + if CONF_NATIVE_WIDTH in defaults: + if CONF_WIDTH not in defaults: + defaults[CONF_WIDTH] = ( + defaults[CONF_NATIVE_WIDTH] + - defaults.get(CONF_OFFSET_WIDTH, 0) + - defaults.get(CONF_PAD_WIDTH, 0) + ) + elif defaults[CONF_WIDTH] > defaults[CONF_NATIVE_WIDTH]: + defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] + + else: + native_width = ( + defaults.get(CONF_WIDTH, 0) + + defaults.get(CONF_OFFSET_WIDTH, 0) + + defaults.get(CONF_PAD_WIDTH, 0) + ) + if native_width != 0: + defaults[CONF_NATIVE_WIDTH] = native_width + if CONF_NATIVE_HEIGHT in defaults: + if CONF_HEIGHT not in defaults: + defaults[CONF_HEIGHT] = ( + defaults[CONF_NATIVE_HEIGHT] + - defaults.get(CONF_OFFSET_HEIGHT, 0) + - defaults.get(CONF_PAD_HEIGHT, 0) + ) + elif defaults[CONF_HEIGHT] > defaults[CONF_NATIVE_HEIGHT]: + defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] + else: + native_height = ( + defaults.get(CONF_HEIGHT, 0) + + defaults.get(CONF_OFFSET_HEIGHT, 0) + + defaults.get(CONF_PAD_HEIGHT, 0) + ) + if native_height != 0: + defaults[CONF_NATIVE_HEIGHT] = native_height self.defaults = defaults DriverChip.models[name] = self @@ -282,18 +383,6 @@ class DriverChip: initsequence = list(kwargs.pop("initsequence", self.initsequence)) initsequence.extend(kwargs.pop("add_init_sequence", ())) defaults = self.defaults.copy() - if ( - CONF_WIDTH in defaults - and CONF_OFFSET_WIDTH in kwargs - and CONF_NATIVE_WIDTH not in defaults - ): - defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH] - if ( - CONF_HEIGHT in defaults - and CONF_OFFSET_HEIGHT in kwargs - and CONF_NATIVE_HEIGHT not in defaults - ): - defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT] defaults.update(kwargs) return self.__class__(name, initsequence=tuple(initsequence), **defaults) @@ -305,11 +394,25 @@ 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: + """ + Check if the model supports hardware transforms for the given configuration. + """ + return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } def option(self, name, fallback=False) -> cv.Optional: return cv.Optional(name, default=self.get_default(name, fallback)) @@ -331,48 +434,89 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + def get_dimensions( + self, config, swap: bool = True + ) -> tuple[int, int, int, int, int, int]: """ Return the dimensions of the current model. :param config: The current configuration :param swap: If width/height should be swapped when axes are swapped. - :return: + :return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height). """ + + transform = self.get_transform(config) if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] if isinstance(dimensions, dict): + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) + if transform.get(CONF_SWAP_XY) is True: + native_width, native_height = native_height, native_width width = dimensions[CONF_WIDTH] height = dimensions[CONF_HEIGHT] offset_width = dimensions[CONF_OFFSET_WIDTH] offset_height = dimensions[CONF_OFFSET_HEIGHT] - return width, height, offset_width, offset_height - (width, height) = dimensions - return width, height, 0, 0 + if CONF_PAD_WIDTH in dimensions: + pad_width = dimensions[CONF_PAD_WIDTH] + native_width = width + offset_width + pad_width + elif native_width == 0: + pad_width = 0 + native_width = width + offset_width + else: + pad_width = native_width - width - offset_width + if CONF_PAD_HEIGHT in dimensions: + pad_height = dimensions[CONF_PAD_HEIGHT] + native_height = height + offset_height + pad_height + elif native_height == 0: + pad_height = 0 + native_height = height + offset_height + else: + pad_height = native_height - height - offset_height + if ( + pad_width + offset_width >= native_width + or pad_height + offset_height >= native_height + ): + raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS]) + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS]) + + return width, height, offset_width, offset_height, pad_width, pad_height + + # Must be a tuple + width, height = dimensions + return width, height, 0, 0, 0, 0 # Default dimensions, use model defaults - transform = self.get_transform(config) width = self.get_default(CONF_WIDTH) height = self.get_default(CONF_HEIGHT) + native_width = self.get_default(CONF_NATIVE_WIDTH, 0) + native_height = self.get_default(CONF_NATIVE_HEIGHT, 0) offset_width = self.get_default(CONF_OFFSET_WIDTH, 0) offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0) + pad_width = self.get_default( + CONF_PAD_WIDTH, native_width - width - offset_width + ) + pad_height = self.get_default( + CONF_PAD_HEIGHT, native_height - height - offset_height + ) + + if pad_width < 0 or pad_height < 0: + raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS]) # if mirroring axes and there are offsets, also mirror the offsets to cater for situations where # the offset is asymmetric if transform.get(CONF_MIRROR_X): - native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2) - offset_width = native_width - width - offset_width + offset_width, pad_width = pad_width, offset_width if transform.get(CONF_MIRROR_Y): - native_height = self.get_default( - CONF_NATIVE_HEIGHT, height + offset_height * 2 - ) - offset_height = native_height - height - offset_height - # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer + offset_height, pad_height = pad_height, offset_height + # Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height - return width, height, offset_width, offset_height + pad_width, pad_height = pad_height, pad_width + return width, height, offset_width, offset_height, pad_width, pad_height def get_base_transform(self, config): transform = config.get( @@ -396,33 +540,35 @@ class DriverChip: def get_transform(self, config) -> dict[str, bool]: transform = self.get_base_transform(config) - can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True + transform[CONF_TRANSFORM] = self.rotation_as_transform(config) return transform - def swap_xy_schema(self): - 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: """ @@ -444,8 +590,8 @@ class DriverChip: return madctl def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - # This takes into account rotation if it can be implemented in the transform + # Add the MADCTL command to the sequence based on the base configuration. + # Rotation is not applied here, it will be done at runtime. transform = self.get_transform(config) madctl = self.get_madctl(transform, config) sequence.append((MADCTL, madctl & 0xFF)) @@ -456,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 ()) @@ -470,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): @@ -490,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 026c214569..b23982655a 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -32,31 +32,31 @@ from esphome.components.mipi import ( dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) import esphome.config_validation as cv 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 esphome.types import ConfigType 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"] @@ -64,14 +64,14 @@ DOMAIN = "mipi_dsi" LOGGER = logging.getLogger(DOMAIN) -MIPI_DSI = mipi_dsi_ns.class_("MIPI_DSI", display.Display, cg.Component) +MipiDsi = mipi_dsi_ns.class_("MipiDsi", display.Display, cg.Component) ColorOrder = display.display_ns.enum("ColorMode") 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 @@ -86,21 +86,9 @@ COLOR_DEPTHS = { } -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All: 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) @@ -113,7 +101,7 @@ def model_schema(config): schema = display.FULL_DISPLAY_SCHEMA.extend( { model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema, - cv.GenerateID(): cv.declare_id(MIPI_DSI), + cv.GenerateID(): cv.declare_id(MipiDsi), cv_dimensions(CONF_DIMENSIONS): dimension_schema( model.get_default(CONF_DRAW_ROUNDING, 1) ), @@ -160,17 +148,35 @@ def model_schema(config): ) -def _config_schema(config): +@model_schema_extractor(MODELS, model_schema) +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), }, extra=cv.ALLOW_EXTRA, )(config) - return model_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) + ) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_rotation=False, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -178,18 +184,19 @@ def _final_validate(config): if not requires_buffer(config) and LVGL_DOMAIN not in global_config: # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True - return config CONFIG_SCHEMA = _config_schema FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] color_depth = COLOR_DEPTHS[get_color_depth(config)] pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit")) - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) sequence = model.get_sequence(config) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 9bd2dded2c..0ff934ae94 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -9,18 +9,18 @@ namespace esphome::mipi_dsi { static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) { - auto sem = static_cast(user_ctx); + SemaphoreHandle_t sem = static_cast(user_ctx); BaseType_t need_yield = pdFALSE; xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); } -void MIPI_DSI::smark_failed(const LogString *message, esp_err_t err) { +void MipiDsi::smark_failed(const LogString *message, esp_err_t err) { ESP_LOGE(TAG, "%s: %s", LOG_STR_ARG(message), esp_err_to_name(err)); this->mark_failed(message); } -void MIPI_DSI::setup() { +void MipiDsi::setup() { ESP_LOGCONFIG(TAG, "Running Setup"); if (!this->enable_pins_.empty()) { @@ -175,7 +175,7 @@ void MIPI_DSI::setup() { ESP_LOGCONFIG(TAG, "MIPI DSI setup complete"); } -void MIPI_DSI::update() { +void MipiDsi::update() { if (this->auto_clear_enabled_) { this->clear(); } @@ -202,8 +202,8 @@ void MIPI_DSI::update() { this->y_high_ = 0; } -void MIPI_DSI::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) { +void MipiDsi::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) { if (w <= 0 || h <= 0) return; // if color mapping is required, pass the buck. @@ -216,8 +216,8 @@ void MIPI_DSI::draw_pixels_at(int x_start, int y_start, int w, int h, const uint this->write_to_display_(x_start, y_start, w, h, ptr, x_offset, y_offset, x_pad); } -void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad) { +void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad) { esp_err_t err = ESP_OK; auto bytes_per_pixel = 3 - this->color_depth_; auto stride = (x_offset + w + x_pad) * bytes_per_pixel; @@ -241,7 +241,7 @@ void MIPI_DSI::write_to_display_(int x_start, int y_start, int w, int h, const u ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); } -bool MIPI_DSI::check_buffer_() { +bool MipiDsi::check_buffer_() { if (this->is_failed()) return false; if (this->buffer_ != nullptr) @@ -257,7 +257,7 @@ bool MIPI_DSI::check_buffer_() { return true; } -void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { +void MipiDsi::draw_pixel_at(int x, int y, Color color) { if (!this->get_clipping().inside(x, y)) return; @@ -280,7 +280,6 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) { return; } - auto pixel = convert_big_endian(display::ColorUtil::color_to_565(color)); if (!this->check_buffer_()) return; size_t pos = (y * this->width_) + x; @@ -319,7 +318,7 @@ void MIPI_DSI::draw_pixel_at(int x, int y, Color color) { if (y > this->y_high_) this->y_high_ = y; } -void MIPI_DSI::fill(Color color) { +void MipiDsi::fill(Color color) { if (!this->check_buffer_()) return; @@ -359,7 +358,7 @@ void MIPI_DSI::fill(Color color) { } } -int MIPI_DSI::get_width() { +int MipiDsi::get_width() { switch (this->rotation_) { case display::DISPLAY_ROTATION_90_DEGREES: case display::DISPLAY_ROTATION_270_DEGREES: @@ -371,7 +370,7 @@ int MIPI_DSI::get_width() { } } -int MIPI_DSI::get_height() { +int MipiDsi::get_height() { switch (this->rotation_) { case display::DISPLAY_ROTATION_0_DEGREES: case display::DISPLAY_ROTATION_180_DEGREES: @@ -385,7 +384,7 @@ int MIPI_DSI::get_height() { static const uint8_t PIXEL_MODES[] = {0, 16, 18, 24}; -void MIPI_DSI::dump_config() { +void MipiDsi::dump_config() { ESP_LOGCONFIG(TAG, "MIPI_DSI RGB LCD" "\n Model: %s" diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 82827d813e..7bf2feb73c 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -35,9 +35,9 @@ 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 MIPI_DSI : public display::Display { +class MipiDsi final : public display::Display { public: - MIPI_DSI(size_t width, size_t height, display::ColorBitness color_depth, uint8_t pixel_mode) + 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) {} display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_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..914361a4ac 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=[ @@ -322,4 +318,232 @@ DriverChip( (0xE0, 0x00), ] ) + +# JC8012P4A1 V2 Driver Configuration (jd9365) +# Some units of this model have a different LCD panel but still use the same JD9365 driver chip. +# Using parameters from esp_lcd_jd9365.h and the working full init sequence +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 800x1280 +# * PCLK Frequency: 70 MHz +# * DSI Lane Bit Rate: 1.5 Gbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40) +# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=10, vsync_front_porch=20) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC8012P4A1-V2", + width=800, + height=1280, + hsync_back_porch=20, + hsync_pulse_width=20, + hsync_front_porch=40, + vsync_back_porch=10, + vsync_pulse_width=4, + vsync_front_porch=20, + pclk_frequency="70MHz", + lane_bit_rate="1500Mbps", + color_order="RGB", + reset_pin=27, + initsequence=[ + (0xE0, 0x00), + (0xE1, 0x93), + (0xE2, 0x65), + (0xE3, 0xF8), + (0x80, 0x01), + (0xE0, 0x01), + (0x00, 0x00), + (0x01, 0x44), + (0x03, 0x10), + (0x04, 0x38), + (0x0C, 0x74), + (0x17, 0x00), + (0x18, 0xAF), + (0x19, 0x00), + (0x1A, 0x00), + (0x1B, 0xAF), + (0x1C, 0x00), + (0x35, 0x26), + (0x37, 0x09), + (0x38, 0x04), + (0x39, 0x00), + (0x3A, 0x01), + (0x3C, 0x78), + (0x3D, 0xFF), + (0x3E, 0xFF), + (0x3F, 0x7F), + (0x40, 0x06), + (0x41, 0xA0), + (0x42, 0x81), + (0x43, 0x1E), + (0x44, 0x0D), + (0x45, 0x28), + (0x55, 0x02), + (0x57, 0x69), + (0x59, 0x0A), + (0x5A, 0x2A), + (0x5B, 0x17), + (0x5D, 0x7F), + (0x5E, 0x6B), + (0x5F, 0x5C), + (0x60, 0x50), + (0x61, 0x4C), + (0x62, 0x3E), + (0x63, 0x41), + (0x64, 0x2B), + (0x65, 0x43), + (0x66, 0x42), + (0x67, 0x43), + (0x68, 0x62), + (0x69, 0x52), + (0x6A, 0x5A), + (0x6B, 0x4C), + (0x6C, 0x48), + (0x6D, 0x3A), + (0x6E, 0x28), + (0x6F, 0x10), + (0x70, 0x7F), + (0x71, 0x6B), + (0x72, 0x5C), + (0x73, 0x50), + (0x74, 0x4C), + (0x75, 0x3E), + (0x76, 0x41), + (0x77, 0x2B), + (0x78, 0x43), + (0x79, 0x42), + (0x7A, 0x43), + (0x7B, 0x62), + (0x7C, 0x52), + (0x7D, 0x5A), + (0x7E, 0x4C), + (0x7F, 0x48), + (0x80, 0x3A), + (0x81, 0x28), + (0x82, 0x10), + (0xE0, 0x02), + (0x00, 0x42), + (0x01, 0x42), + (0x02, 0x40), + (0x03, 0x40), + (0x04, 0x5E), + (0x05, 0x5E), + (0x06, 0x5F), + (0x07, 0x5F), + (0x08, 0x5F), + (0x09, 0x57), + (0x0A, 0x57), + (0x0B, 0x77), + (0x0C, 0x77), + (0x0D, 0x47), + (0x0E, 0x47), + (0x0F, 0x45), + (0x10, 0x45), + (0x11, 0x4B), + (0x12, 0x4B), + (0x13, 0x49), + (0x14, 0x49), + (0x15, 0x5F), + (0x16, 0x41), + (0x17, 0x41), + (0x18, 0x40), + (0x19, 0x40), + (0x1A, 0x5E), + (0x1B, 0x5E), + (0x1C, 0x5F), + (0x1D, 0x5F), + (0x1E, 0x5F), + (0x1F, 0x57), + (0x20, 0x57), + (0x21, 0x77), + (0x22, 0x77), + (0x23, 0x46), + (0x24, 0x46), + (0x25, 0x44), + (0x26, 0x44), + (0x27, 0x4A), + (0x28, 0x4A), + (0x29, 0x48), + (0x2A, 0x48), + (0x2B, 0x5F), + (0x2C, 0x01), + (0x2D, 0x01), + (0x2E, 0x00), + (0x2F, 0x00), + (0x30, 0x1F), + (0x31, 0x1F), + (0x32, 0x1E), + (0x33, 0x1E), + (0x34, 0x1F), + (0x35, 0x17), + (0x36, 0x17), + (0x37, 0x37), + (0x38, 0x37), + (0x39, 0x08), + (0x3A, 0x08), + (0x3B, 0x0A), + (0x3C, 0x0A), + (0x3D, 0x04), + (0x3E, 0x04), + (0x3F, 0x06), + (0x40, 0x06), + (0x41, 0x1F), + (0x42, 0x02), + (0x43, 0x02), + (0x44, 0x00), + (0x45, 0x00), + (0x46, 0x1F), + (0x47, 0x1F), + (0x48, 0x1E), + (0x49, 0x1E), + (0x4A, 0x1F), + (0x4B, 0x17), + (0x4C, 0x17), + (0x4D, 0x37), + (0x4E, 0x37), + (0x4F, 0x09), + (0x50, 0x09), + (0x51, 0x0B), + (0x52, 0x0B), + (0x53, 0x05), + (0x54, 0x05), + (0x55, 0x07), + (0x56, 0x07), + (0x57, 0x1F), + (0x58, 0x40), + (0x5B, 0x30), + (0x5C, 0x00), + (0x5D, 0x34), + (0x5E, 0x05), + (0x5F, 0x02), + (0x63, 0x00), + (0x64, 0x6A), + (0x67, 0x73), + (0x68, 0x07), + (0x69, 0x08), + (0x6A, 0x6A), + (0x6B, 0x08), + (0x6C, 0x00), + (0x6D, 0x00), + (0x6E, 0x00), + (0x6F, 0x88), + (0x75, 0xFF), + (0x77, 0xDD), + (0x78, 0x2C), + (0x79, 0x15), + (0x7A, 0x17), + (0x7D, 0x14), + (0x7E, 0x82), + (0xE0, 0x04), + (0x00, 0x0E), + (0x02, 0xB3), + (0x09, 0x60), + (0x0E, 0x48), + (0x37, 0x58), + (0x2B, 0x0F), + (0xE0, 0x05), + (0x15, 0x1D), + (0xE0, 0x00), + (0xE6, 0x02), + (0xE7, 0x0C) + ] +) # fmt: on diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 2298f76cd4..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,9 +66,9 @@ DriverChip( vsync_front_porch=220, pclk_frequency="80MHz", lane_bit_rate="960Mbps", - swap_xy=cv.UNDEFINED, color_order="RGB", initsequence=[ + (0x01,), (0x60, 0x71, 0x23, 0xa2), (0x60, 0x71, 0x23, 0xa3), (0x60, 0x71, 0x23, 0xa4), @@ -96,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 4952bda95f..e23e19a000 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -1,5 +1,6 @@ import importlib import pkgutil +from typing import Any from esphome import pins import esphome.codegen as cg @@ -18,6 +19,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, @@ -30,15 +33,19 @@ from esphome.components.mipi import ( DriverChip, dimension_schema, map_sequence, + model_schema_extractor, 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 ( + CONF_AUTO_CLEAR_ENABLED, CONF_BLUE, CONF_COLOR_ORDER, CONF_CS_PIN, @@ -46,7 +53,6 @@ from esphome.const import ( CONF_DATA_RATE, CONF_DC_PIN, CONF_DIMENSIONS, - CONF_DISABLED, CONF_ENABLE_PIN, CONF_GREEN, CONF_HSYNC_PIN, @@ -55,8 +61,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, @@ -69,11 +73,12 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType -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) @@ -84,7 +89,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 @@ -94,7 +99,7 @@ for module_info in pkgutil.iter_modules(models.__path__): MODELS = DriverChip.get_models() -def data_pin_validate(value): +def data_pin_validate(value: Any) -> ConfigType: """ It is safe to use strapping pins as RGB output data bits, as they are outputs only, and not initialised until after boot. @@ -109,25 +114,16 @@ def data_pin_validate(value): return DATA_PIN_SCHEMA(value) -def data_pin_set(length): +def data_pin_set(length: int) -> cv.All: return cv.All( [data_pin_validate], cv.Length(min=length, max=length, msg=f"Exactly {length} data pins required"), ) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.Schema: 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 @@ -218,7 +214,8 @@ def model_schema(config): return schema -def _config_schema(config): +@model_schema_extractor(MODELS, model_schema) +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -226,17 +223,34 @@ def _config_schema(config): extra=cv.ALLOW_EXTRA, )(config) schema = model_schema(config) - return cv.All( + config = cv.All( schema, cv.only_on_esp32, 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) + ) + display.add_metadata( + config[CONF_ID], + width, + height, + model.rotation_as_transform(config), + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) + return config CONFIG_SCHEMA = _config_schema -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN @@ -248,15 +262,16 @@ def _final_validate(config): config = spi.final_validate_device_schema( "mipi_rgb", require_miso=False, require_mosi=True )(config) - return config FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = MODELS[config[CONF_MODEL].upper()] - width, height, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) var = cg.new_Pvariable(config[CONF_ID], width, height) cg.add(var.set_model(model.name)) if enable_pin := config.get(CONF_ENABLE_PIN): diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index b07460fdba..7421d8ad83 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -345,7 +345,7 @@ int MipiRgb::get_height() { } } -static const char *get_pin_name(GPIOPin *pin, std::span buffer) { +[[maybe_unused]] static const char *get_pin_name(GPIOPin *pin, std::span buffer) { if (pin == nullptr) return "None"; pin->dump_summary(buffer.data(), buffer.size()); diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 4d1d836099..1480004833 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -30,9 +30,6 @@ class MipiRgb : public display::Display { void fill(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; - void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, - int x_pad); - bool check_buffer_(); display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } @@ -60,12 +57,15 @@ class MipiRgb : public display::Display { display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } int get_width_internal() override { return this->width_; } int get_height_internal() override { return this->height_; } - void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void dump_config() override; void draw_pixel_at(int x, int y, Color color) override; // this will be horribly slow. protected: + void write_to_display_(int x_start, int y_start, int w, int h, const uint8_t *ptr, int x_offset, int y_offset, + int x_pad); + bool check_buffer_(); + void dump_pins_(uint8_t start, uint8_t end, const char *name, uint8_t offset); void setup_enables_(); void common_setup_(); InternalGPIOPin *de_pin_{nullptr}; @@ -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..cad5dc8e20 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -1,19 +1,14 @@ -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): + def add_madctl(self, sequence: list, config: dict) -> int: transform = self.get_transform(config) madctl = 0x00 if config[CONF_COLOR_ORDER] == MODE_BGR: @@ -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 364ada9046..e8b54da5c7 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -17,19 +17,23 @@ from esphome.components.mipi import ( MADCTL, MODE_BGR, MODE_RGB, + PAGESEL, + PAGESEL1, PIXFMT, DriverChip, dimension_schema, get_color_depth, map_sequence, + model_schema_extractor, power_of_two, requires_buffer, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN -from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE +from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE import esphome.config_validation as cv from esphome.config_validation import ALLOW_EXTRA from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, CONF_BRIGHTNESS, CONF_BUFFER_SIZE, CONF_COLOR_ORDER, @@ -37,22 +41,21 @@ 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, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.cpp_generator import TemplateArguments +from esphome.cpp_generator import MockObjClass, TemplateArguments from esphome.final_validate import full_config +from esphome.types import ConfigType from . import CONF_BUS_MODE, CONF_SPI_16, DOMAIN, models @@ -108,7 +111,7 @@ DISPLAY_PIXEL_MODES = { } -def denominator(config): +def denominator(config: ConfigType) -> int: """ Calculate the best denominator for a buffer size fraction. The denominator should be a number between 2 and 16 that divides the display height evenly, @@ -118,7 +121,9 @@ def denominator(config): """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - _width, height, _offset_width, _offset_height = model.get_dimensions(config) + _width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) if frac is None or frac > 0.75 or height < 32: return 1 try: @@ -128,19 +133,10 @@ def denominator(config): return next(x for x in range(2, 17) if frac >= 1 / x) -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All | cv.Schema: 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) @@ -166,11 +162,28 @@ def model_schema(config): ] if bus_mode == TYPE_SINGLE: other_options.append(CONF_SPI_16) + # Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise. + spi_mode = ( + cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE) + ) + if not spi_mode: + if bus_mode == TYPE_OCTAL or ( + bus_mode == TYPE_SINGLE + and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False + ): + spi_mode = "MODE3" + if bus_mode == TYPE_SINGLE: + LOGGER.warning( + "No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3." + ) + else: + spi_mode = "MODE0" + schema = ( display.FULL_DISPLAY_SCHEMA.extend( spi.spi_device_schema( cs_pin_required=False, - default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0", + default_mode=spi_mode, default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000), mode=bus_mode, ) @@ -225,7 +238,8 @@ def model_schema(config): return schema -def customise_schema(config): +@model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) +def customise_schema(config: ConfigType) -> ConfigType: """ Create a customised config schema for a specific model and validate the configuration. :param config: The configuration dictionary to validate @@ -240,6 +254,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( { @@ -253,27 +268,45 @@ def customise_schema(config): # Check for invalid combinations of MADCTL config if init_sequence := config.get(CONF_INIT_SEQUENCE): commands = [x[0] for x in init_sequence] - if MADCTL in commands and CONF_TRANSFORM in config: - raise cv.Invalid( - f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" - ) - if PIXFMT in commands: - raise cv.Invalid( - f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" - ) + # If there is page swapping, we can't rely on recognising common commands + if PAGESEL not in commands and PAGESEL1 not in commands: + if MADCTL in commands and CONF_TRANSFORM in config: + raise cv.Invalid( + f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence" + ) + if PIXFMT in commands: + raise cv.Invalid( + f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically" + ) if bus_mode == TYPE_QUAD and CONF_DC_PIN in config: raise cv.Invalid("DC pin is not supported in quad mode") if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config: raise cv.Invalid(f"DC pin is required in {bus_mode} mode") denominator(config) + model = MODELS[config[CONF_MODEL]] + has_hardware_transform = model.has_hardware_transform(config) + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config, not has_hardware_transform) + ) + display.add_metadata( + config[CONF_ID], + width, + height, + has_hardware_transform, + byte_order=config[CONF_BYTE_ORDER], + has_writer=requires_buffer(config) + or config.get(CONF_AUTO_CLEAR_ENABLED) is True, + rotation=config.get(CONF_ROTATION, 0), + draw_rounding=config.get(CONF_DRAW_ROUNDING, 0), + ) return config CONFIG_SCHEMA = customise_schema -def _final_validate(config): +def _final_validate(config: ConfigType) -> None: global_config = full_config.get() model = MODELS[config[CONF_MODEL]] @@ -287,14 +320,17 @@ def _final_validate(config): # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True + # Always call this to check dimensions during validation + width, height, _offset_width, _offset_height, _pad_width, _pad_height = ( + model.get_dimensions(config) + ) + if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): return # No need to pick a size color_depth = get_color_depth(config) frac = denominator(config) - width, height, _offset_width, _offset_height = model.get_dimensions(config) - buffer_size = color_depth // 8 * width * height // frac # Target a buffer size of 20kB, except for large displays, which shouldn't end up here fraction = min(20000.0, buffer_size // 4) / buffer_size @@ -306,7 +342,7 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -def get_instance(config): +def get_instance(config: ConfigType) -> tuple[MockObjClass, list]: """ Get the type of MipiSpi instance to create based on the configuration, and the template arguments. @@ -314,15 +350,9 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - has_hardware_transform = config.get( - CONF_TRANSFORM - ) != CONF_DISABLED and model.transforms == { - CONF_MIRROR_X, - CONF_MIRROR_Y, - CONF_SWAP_XY, - } - width, height, offset_width, offset_height = model.get_dimensions( - config, not has_hardware_transform + has_hardware_transform = model.has_hardware_transform(config) + width, height, offset_width, offset_height, pad_width, pad_height = ( + model.get_dimensions(config, not has_hardware_transform) ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) @@ -338,7 +368,6 @@ def get_instance(config): buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) madctl = model.get_madctl(model.get_base_transform(config), config) - has_writer = requires_buffer(config) templateargs = [ buffer_type, bufferpixels, @@ -349,12 +378,11 @@ def get_instance(config): height, offset_width, offset_height, + pad_width, + pad_height, madctl, has_hardware_transform, ] - display.add_metadata( - config[CONF_ID], width, height, has_writer, has_hardware_transform - ) # If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi if requires_buffer(config): templateargs.extend( @@ -367,10 +395,10 @@ def get_instance(config): return MipiSpi, templateargs -async def to_code(config): +async def to_code(config: ConfigType) -> None: 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)) @@ -387,6 +415,8 @@ async def to_code(config): dc_pin = await cg.gpio_pin_expression(dc_pin) cg.add(var.set_dc_pin(dc_pin)) + if config.get(CONF_INVERT_COLORS): + cg.add(var.set_invert_colors(True)) if lamb := config.get(CONF_LAMBDA): lambda_ = await cg.process_lambda( lamb, [(display.DisplayRef, "it")], return_type=cg.void diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 5023cf8089..2552451bd7 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; @@ -81,10 +83,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -126,17 +133,6 @@ class MipiSpi : public display::Display, return HEIGHT; } - // If hardware rotation is in use, the actual display width/height changes with rotation - int get_width_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_width(); - return WIDTH; - } - int get_height_internal() override { - if constexpr (HAS_HARDWARE_ROTATION) - return get_height(); - return HEIGHT; - } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -157,11 +153,11 @@ class MipiSpi : public display::Display, this->reset_pin_->digital_write(false); delay(5); this->reset_pin_->digital_write(true); + // 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()) { @@ -173,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 { @@ -182,32 +181,9 @@ class MipiSpi : public display::Display, this->mark_failed(); return; } - auto arg_byte = vec[index]; - 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; - - case INVERT_ON: - this->invert_colors_ = true; - break; - case BRIGHTNESS: - this->brightness_ = arg_byte; - 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_(); @@ -233,14 +209,25 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, - (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, - this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, - HAS_HARDWARE_ROTATION); + internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(), + this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, + IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, + this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION); } protected: /* METHODS */ + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } // convenience functions to write commands with or without data void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); } void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); } @@ -259,33 +246,36 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - this->dc_pin_->digital_write(false); + // Toggle D/C only while holding the bus; on boards where D/C doubles as + // another bus signal, driving it while another device owns the bus + // corrupts that device's transfer. this->enable(); + this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); if (len != 0) { this->enable(); this->write_array(bytes, len); this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->dc_pin_->digital_write(false); this->enable(); + this->dc_pin_->digital_write(false); this->write_byte(cmd); - this->disable(); this->dc_pin_->digital_write(true); + this->disable(); for (size_t i = 0; i != len; i++) { this->enable(); this->write_byte(0); @@ -330,20 +320,34 @@ class MipiSpi : public display::Display, this->write_command_(MADCTL_CMD, madctl); } - uint16_t get_offset_width_() { + uint16_t get_offset_width_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_HEIGHT; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return OFFSET_HEIGHT; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_270_DEGREES: + return PAD_HEIGHT; + default: + break; + } } return OFFSET_WIDTH; } - uint16_t get_offset_height_() { + uint16_t get_offset_height_() const { if constexpr (HAS_HARDWARE_ROTATION) { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return OFFSET_WIDTH; + switch (this->rotation_) { + case display::DISPLAY_ROTATION_90_DEGREES: + return PAD_WIDTH; + case display::DISPLAY_ROTATION_180_DEGREES: + return PAD_HEIGHT; + case display::DISPLAY_ROTATION_270_DEGREES: + return OFFSET_WIDTH; + default: + break; + } } return OFFSET_HEIGHT; } @@ -384,10 +388,10 @@ class MipiSpi : public display::Display, * @param ptr The pointer to the pixel data * @param w Width of each line in bytes * @param h Height of the buffer in rows - * @param pad Padding in bytes after each line + * @param stride Total length of each line in bytes, including any padding */ - void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) { - if (pad == 0) { + void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) { + if (stride == w) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w * h); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -396,7 +400,7 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8); } } else { - for (size_t y = 0; y != static_cast(h); y++) { + for (size_t y = 0; y != h; y++) { if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) { this->write_array(ptr, w); } else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { @@ -404,7 +408,7 @@ class MipiSpi : public display::Display, } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8); } - ptr += w + pad; + ptr += stride; } } } @@ -422,7 +426,7 @@ class MipiSpi : public display::Display, ptr += y_offset * (x_offset + w + x_pad) + x_offset; if constexpr (BUFFERPIXEL == DISPLAYPIXEL) { this->write_display_data_(reinterpret_cast(ptr), w * sizeof(BUFFERTYPE), h, - x_pad * sizeof(BUFFERTYPE)); + (x_offset + w + x_pad) * sizeof(BUFFERTYPE)); } else { // type conversion required, do it in chunks uint8_t dbuffer[DISPLAYPIXEL * 48]; @@ -458,14 +462,14 @@ class MipiSpi : public display::Display, } // buffer full? Flush. if (dptr == dbuffer + sizeof(dbuffer)) { - this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0); + this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer)); dptr = dbuffer; } } } // flush any remaining data if (dptr != dbuffer) { - this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0); + this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer); } } this->disable(); @@ -492,19 +496,23 @@ class MipiSpi : public display::Display, * @tparam BUFFERPIXEL Color depth of the buffer * @tparam DISPLAYPIXEL Color depth of the display * @tparam BUS_TYPE The type of the interface bus (single, quad, octal) - * @tparam ROTATION The rotation of the display * @tparam WIDTH Width of the display in pixels * @tparam HEIGHT Height of the display in pixels * @tparam OFFSET_WIDTH The x-offset of the display in pixels * @tparam OFFSET_HEIGHT The y-offset of the display in pixels + * @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width + * @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width + * @tparam MADCTL The base MADCTL value for the display, with no rotation bits set. + * @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation. * @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer). * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template -class MipiSpiBuffer : public MipiSpi { + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, + uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> +class MipiSpiBuffer + : public MipiSpi { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and @@ -515,7 +523,7 @@ class MipiSpiBuffer : public MipiSpi::dump_config(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -528,7 +536,7 @@ class MipiSpiBuffer : public MipiSpi::setup(); + PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup(); RAMAllocator allocator{}; this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { diff --git a/esphome/components/mipi_spi/models/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 ae6accb907..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,71 +22,14 @@ from esphome.components.mipi import ( PWCTR5, PWSET, PWSETN, - SETEXTC, - SWRESET, VMCTR, VMCTR1, VMCTR2, VSCRSADD, DriverChip, - delay, ) 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, @@ -176,19 +118,6 @@ ILI9342 = DriverChip( ), ) -# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation -ILI9341.extend( - "M5CORE2", - 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, @@ -364,7 +293,6 @@ ST7796 = DriverChip( width=320, height=480, initsequence=( - (SWRESET,), (CSCON, 0xC3), (CSCON, 0x96), (VMCTR1, 0x1C), @@ -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), @@ -725,8 +655,6 @@ DriverChip( width=128, height=160, initsequence=( - SWRESET, - delay(10), (FRMCTR1, 0x01, 0x2C, 0x2D), (FRMCTR2, 0x01, 0x2C, 0x2D), (FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D), @@ -783,6 +711,32 @@ ST7796.extend( bus_mode=TYPE_OCTAL, mirror_x=True, reset_pin=4, - dc_pin=0, + dc_pin={"number": 0, "ignore_strapping_warning": True}, invert_colors=True, + requires={"psram"}, +) + +ST7789V.extend( + "GEEKMAGIC-SMALLTV", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=2, + dc_pin=0, +) +ST7789V.extend( + "GEEKMAGIC-SMALLTV-PRO", + data_rate="40MHz", + height=240, + width=240, + offset_width=0, + offset_height=0, + invert_colors=True, + buffer_size=0.125, + reset_pin=4, + dc_pin=2, ) diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 854814f572..8d2591aefe 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), @@ -259,14 +266,13 @@ DriverChip( "JC3636W518V2", height=360, width=360, - offset_height=1, - draw_rounding=1, cs_pin=10, reset_pin=47, invert_colors=True, color_order=MODE_RGB, bus_mode=TYPE_QUAD, data_rate="40MHz", + requires={"psram"}, initsequence=( (0xF0, 0x28), (0xF2, 0x28), @@ -495,6 +501,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/st77916.py b/esphome/components/mipi_spi/models/st77916.py new file mode 100644 index 0000000000..38852f135f --- /dev/null +++ b/esphome/components/mipi_spi/models/st77916.py @@ -0,0 +1,269 @@ +# Init sequence sourced from Espressif's esp-bsp repository: +# https://github.com/espressif/esp-bsp/blob/master/bsp/esp_vocat/priv_include/disp_init_data.h +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +from esphome.components.mipi import MODE_RGB, DriverChip +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_INVERTED, CONF_NUMBER + +# Init sequence for the ST77916 QSPI display on the ESP-VoCat v1.2 board. +# Source: disp_init_data.h from espressif/esp-bsp (bsp/esp_vocat/priv_include). +# The standard INVON/INVOFF and SLPOUT+DISPON commands (and required delays) are omitted because +# the mipi_spi framework appends them automatically based on the invert_colors and no_slpout settings. +# (So this sequence only contains panel-specific setup commands.) +_ESP_VOCAT_INIT = ( + # Page 1a — startup unlock + (0xF0, 0x28), + (0xF2, 0x28), + (0x73, 0xF0), + (0x7C, 0xD1), + (0x83, 0xE0), + (0x84, 0x61), + (0xF2, 0x82), + # Switch to page 0 → page 1 + (0xF0, 0x00), + (0xF0, 0x01), + (0xF1, 0x01), + # Power settings (Bx) + (0xB0, 0x56), + (0xB1, 0x4D), + (0xB2, 0x24), + (0xB4, 0x87), + (0xB5, 0x44), + (0xB6, 0x8B), + (0xB7, 0x40), + (0xB8, 0x86), + (0xBA, 0x00), + (0xBB, 0x08), + (0xBC, 0x08), + (0xBD, 0x00), + # VCOM / gate settings (Cx) + (0xC0, 0x80), + (0xC1, 0x10), + (0xC2, 0x37), + (0xC3, 0x80), + (0xC4, 0x10), + (0xC5, 0x37), + (0xC6, 0xA9), + (0xC7, 0x41), + (0xC8, 0x01), + (0xC9, 0xA9), + (0xCA, 0x41), + (0xCB, 0x01), + # Source settings (Dx) + (0xD0, 0x91), + (0xD1, 0x68), + (0xD2, 0x68), + # Misc + (0xF5, 0x00, 0xA5), + (0xDD, 0x4F), + (0xDE, 0x4F), + (0xF1, 0x10), + (0xF0, 0x00), + # Switch to page 2 — gamma + (0xF0, 0x02), + ( + 0xE0, + 0xF0, + 0x0A, + 0x10, + 0x09, + 0x09, + 0x36, + 0x35, + 0x33, + 0x4A, + 0x29, + 0x15, + 0x15, + 0x2E, + 0x34, + ), + ( + 0xE1, + 0xF0, + 0x0A, + 0x0F, + 0x08, + 0x08, + 0x05, + 0x34, + 0x33, + 0x4A, + 0x39, + 0x15, + 0x15, + 0x2D, + 0x33, + ), + # Switch to page 10 — GIP / timing + (0xF0, 0x10), + (0xF3, 0x10), + # Page 10: Exxx + (0xE0, 0x07), + (0xE1, 0x00), + (0xE2, 0x00), + (0xE3, 0x00), + (0xE4, 0xE0), + (0xE5, 0x06), + (0xE6, 0x21), + (0xE7, 0x01), + (0xE8, 0x05), + (0xE9, 0x02), + (0xEA, 0xDA), + (0xEB, 0x00), + (0xEC, 0x00), + (0xED, 0x0F), + (0xEE, 0x00), + (0xEF, 0x00), + # Page 10: Fxxx + (0xF8, 0x00), + (0xF9, 0x00), + (0xFA, 0x00), + (0xFB, 0x00), + (0xFC, 0x00), + (0xFD, 0x00), + (0xFE, 0x00), + (0xFF, 0x00), + # GIP section A (0x60–0x6B) + (0x60, 0x40), + (0x61, 0x04), + (0x62, 0x00), + (0x63, 0x42), + (0x64, 0xD9), + (0x65, 0x00), + (0x66, 0x00), + (0x67, 0x00), + (0x68, 0x00), + (0x69, 0x00), + (0x6A, 0x00), + (0x6B, 0x00), + # GIP section B (0x70–0x7B) + (0x70, 0x40), + (0x71, 0x03), + (0x72, 0x00), + (0x73, 0x42), + (0x74, 0xD8), + (0x75, 0x00), + (0x76, 0x00), + (0x77, 0x00), + (0x78, 0x00), + (0x79, 0x00), + (0x7A, 0x00), + (0x7B, 0x00), + # GIP timing (0x80–0x9F) + (0x80, 0x48), + (0x81, 0x00), + (0x82, 0x06), + (0x83, 0x02), + (0x84, 0xD6), + (0x85, 0x04), + (0x86, 0x00), + (0x87, 0x00), + (0x88, 0x48), + (0x89, 0x00), + (0x8A, 0x08), + (0x8B, 0x02), + (0x8C, 0xD8), + (0x8D, 0x04), + (0x8E, 0x00), + (0x8F, 0x00), + (0x90, 0x48), + (0x91, 0x00), + (0x92, 0x0A), + (0x93, 0x02), + (0x94, 0xDA), + (0x95, 0x04), + (0x96, 0x00), + (0x97, 0x00), + (0x98, 0x48), + (0x99, 0x00), + (0x9A, 0x0C), + (0x9B, 0x02), + (0x9C, 0xDC), + (0x9D, 0x04), + (0x9E, 0x00), + (0x9F, 0x00), + # GIP timing (0xA0–0xBF) + (0xA0, 0x48), + (0xA1, 0x00), + (0xA2, 0x05), + (0xA3, 0x02), + (0xA4, 0xD5), + (0xA5, 0x04), + (0xA6, 0x00), + (0xA7, 0x00), + (0xA8, 0x48), + (0xA9, 0x00), + (0xAA, 0x07), + (0xAB, 0x02), + (0xAC, 0xD7), + (0xAD, 0x04), + (0xAE, 0x00), + (0xAF, 0x00), + (0xB0, 0x48), + (0xB1, 0x00), + (0xB2, 0x09), + (0xB3, 0x02), + (0xB4, 0xD9), + (0xB5, 0x04), + (0xB6, 0x00), + (0xB7, 0x00), + (0xB8, 0x48), + (0xB9, 0x00), + (0xBA, 0x0B), + (0xBB, 0x02), + (0xBC, 0xDB), + (0xBD, 0x04), + (0xBE, 0x00), + (0xBF, 0x00), + # Source timing (0xC0–0xC9) + (0xC0, 0x10), + (0xC1, 0x47), + (0xC2, 0x56), + (0xC3, 0x65), + (0xC4, 0x74), + (0xC5, 0x88), + (0xC6, 0x99), + (0xC7, 0x01), + (0xC8, 0xBB), + (0xC9, 0xAA), + # Source timing (0xD0–0xD9) + (0xD0, 0x10), + (0xD1, 0x47), + (0xD2, 0x56), + (0xD3, 0x65), + (0xD4, 0x74), + (0xD5, 0x88), + (0xD6, 0x99), + (0xD7, 0x01), + (0xD8, 0xBB), + (0xD9, 0xAA), + # Finalise page 10, return to page 0 + (0xF3, 0x01), + (0xF0, 0x00), + # INVON (0x21) and SLPOUT (0x11) are appended by the framework. +) + +DriverChip( + "ESP-VOCAT", + width=360, + height=360, + # SPI pins for the ESP-VoCat v1.2 board. + # PCLK (GPIO18) and data pins (GPIO46/13/11/12) are configured on the spi: bus. + cs_pin=14, + # RST is active-HIGH on this panel; invert the ESPHome pin so the framework's + # active-low reset pulse (HIGH→LOW→HIGH) maps to the correct physical sequence + # (LOW→HIGH→LOW) on the wire. + reset_pin={CONF_NUMBER: 47, CONF_INVERTED: True}, + # Note: GPIO9 behaviour varies by board revision (may be POWER_CTRL, not LCD_EN). + # Do not set a default enable_pin — manage LCD power in on_boot if needed. + # GPIO44 is the backlight; manage it separately via an output: or light:. + bus_mode=TYPE_QUAD, + data_rate="80MHz", + invert_colors=True, + color_order=MODE_RGB, + requires={"psram"}, + initsequence=_ESP_VOCAT_INIT, +) diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index ee8bd06700..bdd0c3c90b 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,22 @@ CO5300.extend( offset_width=6, cs_pin=12, reset_pin=39, + requires={"psram"}, +) + +# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller) +# Pin assignments are the same as the 1.75" devkit: CS=12, RESET=39. Width/height set to 480x480. +CO5300.extend( + "WAVESHARE-ESP32-S3-TOUCH-AMOLED-2.16", + width=480, + height=480, + pixel_mode="16bit", + offset_height=0, + offset_width=0, + cs_pin=12, + reset_pin=39, + data_rate="40MHz", + requires={"psram"}, ) AXS15231.extend( @@ -184,8 +200,57 @@ AXS15231.extend( data_rate="80MHz", cs_pin=9, reset_pin=21, + requires={"psram"}, ) +# Init sequence and pins taken from the vendor demo: +# "ESP32-S3-Touch-LCD-3.5B-Demo/Arduino/examples/09_lvgl_arduino_v8" +# The panel has no reset line (demo passes RST = -1); the sleep-out, display-on +# and first RAM write are issued by the framework, so they are omitted here. +# fmt: off +AXS15231.extend( + "WAVESHARE-ESP32-S3-TOUCH-LCD-3.5B", + width=320, + height=480, + # Vendor demo runs the AXS15231B at 32MHz; the ESP32 SPI clock cannot hit + # that exactly, data sheet says max 50MHz, so use 40MHz (proven on the same controller, JC3248W535) + data_rate="40MHz", + cs_pin=12, + requires={"psram"}, + initsequence=( + (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5), + (0xA0, 0xC0, 0x10, 0x00, 0x02, 0x00, 0x00, 0x04, 0x3F, 0x20, 0x05, 0x3F, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00), + (0xA2, 0x30, 0x3C, 0x24, 0x14, 0xD0, 0x20, 0xFF, 0xE0, 0x40, 0x19, 0x80, 0x80, 0x80, 0x20, 0xF9, 0x10, 0x02, 0xFF, 0xFF, 0xF0, 0x90, 0x01, 0x32, 0xA0, 0x91, 0xE0, 0x20, 0x7F, 0xFF, 0x00, 0x5A), + (0xD0, 0xE0, 0x40, 0x51, 0x24, 0x08, 0x05, 0x10, 0x01, 0x20, 0x15, 0xC2, 0x42, 0x22, 0x22, 0xAA, 0x03, 0x10, 0x12, 0x60, 0x14, 0x1E, 0x51, 0x15, 0x00, 0x8A, 0x20, 0x00, 0x03, 0x3A, 0x12), + (0xA3, 0xA0, 0x06, 0xAA, 0x00, 0x08, 0x02, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x55, 0x55), + (0xC1, 0x31, 0x04, 0x02, 0x02, 0x71, 0x05, 0x24, 0x55, 0x02, 0x00, 0x41, 0x00, 0x53, 0xFF, 0xFF, 0xFF, 0x4F, 0x52, 0x00, 0x4F, 0x52, 0x00, 0x45, 0x3B, 0x0B, 0x02, 0x0D, 0x00, 0xFF, 0x40), + (0xC3, 0x00, 0x00, 0x00, 0x50, 0x03, 0x00, 0x00, 0x00, 0x01, 0x80, 0x01), + (0xC4, 0x00, 0x24, 0x33, 0x80, 0x00, 0xEA, 0x64, 0x32, 0xC8, 0x64, 0xC8, 0x32, 0x90, 0x90, 0x11, 0x06, 0xDC, 0xFA, 0x00, 0x00, 0x80, 0xFE, 0x10, 0x10, 0x00, 0x0A, 0x0A, 0x44, 0x50), + (0xC5, 0x18, 0x00, 0x00, 0x03, 0xFE, 0x3A, 0x4A, 0x20, 0x30, 0x10, 0x88, 0xDE, 0x0D, 0x08, 0x0F, 0x0F, 0x01, 0x3A, 0x4A, 0x20, 0x10, 0x10, 0x00), + (0xC6, 0x05, 0x0A, 0x05, 0x0A, 0x00, 0xE0, 0x2E, 0x0B, 0x12, 0x22, 0x12, 0x22, 0x01, 0x03, 0x00, 0x3F, 0x6A, 0x18, 0xC8, 0x22), + (0xC7, 0x50, 0x32, 0x28, 0x00, 0xA2, 0x80, 0x8F, 0x00, 0x80, 0xFF, 0x07, 0x11, 0x9C, 0x67, 0xFF, 0x24, 0x0C, 0x0D, 0x0E, 0x0F), + (0xC9, 0x33, 0x44, 0x44, 0x01), + (0xCF, 0x2C, 0x1E, 0x88, 0x58, 0x13, 0x18, 0x56, 0x18, 0x1E, 0x68, 0x88, 0x00, 0x65, 0x09, 0x22, 0xC4, 0x0C, 0x77, 0x22, 0x44, 0xAA, 0x55, 0x08, 0x08, 0x12, 0xA0, 0x08), + (0xD5, 0x40, 0x8E, 0x8D, 0x01, 0x35, 0x04, 0x92, 0x74, 0x04, 0x92, 0x74, 0x04, 0x08, 0x6A, 0x04, 0x46, 0x03, 0x03, 0x03, 0x03, 0x82, 0x01, 0x03, 0x00, 0xE0, 0x51, 0xA1, 0x00, 0x00, 0x00), + (0xD6, 0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE, 0x93, 0x00, 0x01, 0x83, 0x07, 0x07, 0x00, 0x07, 0x07, 0x00, 0x03, 0x03, 0x03, 0x03, 0x03, 0x03, 0x00, 0x84, 0x00, 0x20, 0x01, 0x00), + (0xD7, 0x03, 0x01, 0x0B, 0x09, 0x0F, 0x0D, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19, 0x40, 0x8E, 0x04, 0x00, 0x20, 0xA0, 0x1F), + (0xD8, 0x02, 0x00, 0x0A, 0x08, 0x0E, 0x0C, 0x1E, 0x1F, 0x18, 0x1D, 0x1F, 0x19), + (0xD9, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F), + (0xDD, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F), + (0xDF, 0x44, 0x73, 0x4B, 0x69, 0x00, 0x0A, 0x02, 0x90), + (0xE0, 0x3B, 0x28, 0x10, 0x16, 0x0C, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x13, 0x2C, 0x33, 0x28, 0x0D), + (0xE1, 0x37, 0x28, 0x10, 0x16, 0x0B, 0x06, 0x11, 0x28, 0x5C, 0x21, 0x0D, 0x35, 0x14, 0x2C, 0x33, 0x28, 0x0F), + (0xE2, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D), + (0xE3, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x35, 0x44, 0x32, 0x0C, 0x14, 0x14, 0x36, 0x32, 0x2F, 0x0F), + (0xE4, 0x3B, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0D), + (0xE5, 0x37, 0x07, 0x12, 0x18, 0x0E, 0x0D, 0x17, 0x39, 0x44, 0x2E, 0x0C, 0x14, 0x14, 0x36, 0x3A, 0x2F, 0x0F), + (0xA4, 0x85, 0x85, 0x95, 0x82, 0xAF, 0xAA, 0xAA, 0x80, 0x10, 0x30, 0x40, 0x40, 0x20, 0xFF, 0x60, 0x30), + (0xA4, 0x85, 0x85, 0x95, 0x85), + (0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + ), +) +# fmt: on + # Waveshare 1.83-v2 # # Do not use on 1.83-v1: Vendor warning on different chip! @@ -255,3 +320,28 @@ ST7789V.extend( cs_pin=14, dc_pin={"number": 15, "ignore_strapping_warning": True}, ) + +ST7789V.extend( + "WAVESHARE-ESP32-S3-GEEK", + cs_pin=10, + dc_pin=8, + reset_pin=9, + width=135, + height=240, + offset_width=52, + offset_height=40, + invert_colors=True, + data_rate="40MHz", + 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/climate.py b/esphome/components/mitsubishi/climate.py index 8291d70346..2d38351898 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import climate_ir import esphome.config_validation as cv +from esphome.types import ConfigType CODEOWNERS = ["@RubyBailey"] AUTO_LOAD = ["climate_ir"] @@ -58,7 +59,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_fan_mode(config[CONF_SET_FAN_MODE])) 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/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index e69de29bb2..470b7be5fc 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -0,0 +1,250 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_ON_STATE, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, + CONF_USE_FAHRENHEIT, +) +from esphome.core import ID, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@crnjan"] +DEPENDENCIES = ["uart"] +DOMAIN = "mitsubishi_cn105" + +CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" +CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" +CONF_VANE = "vane" +CONF_VERTICAL = "vertical" + +mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) + +MitsubishiCN105Component = mitsubishi_ns.class_( + "MitsubishiCN105Component", + cg.Component, + uart.UARTDevice, +) + +VaneState = mitsubishi_ns.struct("VaneState") +VaneCall = mitsubishi_ns.class_("VaneCall") +VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") + +# The insertion order must match VALUES in +# select/mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = { + "AUTO": VerticalVaneMode.VERTICAL_VANE_MODE_AUTO, + "1": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_1, + "2": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_2, + "3": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_3, + "4": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_4, + "5": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_5, + "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, +} + +SetRemoteTemperatureAction = mitsubishi_ns.class_( + "SetRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +ClearRemoteTemperatureAction = mitsubishi_ns.class_( + "ClearRemoteTemperatureAction", + automation.Action, + cg.Parented.template(MitsubishiCN105Component), +) + +VaneControlAction = mitsubishi_ns.class_( + "VaneControlAction", + automation.Action, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MitsubishiCN105Component), + cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, + cv.Optional( + CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" + ): cv.update_interval, + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + cv.Optional(CONF_VANE): cv.Schema( + { + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +MITSUBISHI_CN105_DEVICE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MITSUBISHI_CN105_ID): cv.use_id(MitsubishiCN105Component), + } +) + +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + DOMAIN, + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) +) + + +async def register_mitsubishi_cn105_device(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_MITSUBISHI_CN105_ID]) + cg.add(var.set_parent(parent)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + cg.add( + var.set_telemetry_request_min_interval( + config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] + ) + ) + cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT])) + if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): + cg.add_global(mitsubishi_ns.using) + for conf in on_state: + await automation.build_callback_automation( + var, + "add_on_vane_state_callback", + [(VaneState.operator("const").operator("ref"), "x")], + conf, + ) + + +REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + } +) + + +@automation.register_action( + f"{DOMAIN}.set_remote_temperature", + SetRemoteTemperatureAction, + REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def remote_temperature_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) + cg.add(var.set_temperature(temperature)) + return var + + +@automation.register_action( + f"{DOMAIN}.clear_remote_temperature", + ClearRemoteTemperatureAction, + CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, + synchronous=True, +) +async def clear_temperature_action_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +VANE_CONTROL_FIELDS = ( + ( + (CONF_VERTICAL, CONF_DIRECTION), + "vertical.set_direction", + VerticalVaneMode, + ), +) + +VANE_CONTROL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Optional(CONF_VERTICAL): cv.Schema( + { + cv.Optional(CONF_DIRECTION): cv.templatable( + cv.enum(VERTICAL_VANE_DIRECTIONS, upper=True) + ), + } + ), + } +) + + +@automation.register_action( + f"{DOMAIN}.vane.control", + VaneControlAction, + VANE_CONTROL_ACTION_SCHEMA, + synchronous=True, +) +async def vane_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + cg.add_global(mitsubishi_ns.using) + parent = await cg.get_variable(config[CONF_ID]) + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) + for t, name in args + ] + forwarded_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for path, setter, type_ in VANE_CONTROL_FIELDS: + if (section := config.get(path[0])) is None: + continue + if (value := section.get(path[1])) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda( + value, + normalized_args, + return_type=type_, + ) + body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + [(VaneCall.operator("ref"), "call"), *normalized_args], + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h new file mode 100644 index 0000000000..f9ca3a47e6 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -0,0 +1,42 @@ +#pragma once + +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/automation.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +template +class SetRemoteTemperatureAction final : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, temperature) + + void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } +}; + +template +class ClearRemoteTemperatureAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } +}; + +template class VaneControlAction final : public Action { + public: + using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); + + VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} + + void play(const Ts &...x) override { + auto call = this->parent_->make_vane_call(); + this->apply_(call, x...); + call.perform(); + } + + protected: + MitsubishiCN105Component *parent_; + ApplyFn apply_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py index cc44494d89..05a29b3665 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,122 +1,256 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart +from esphome.components.climate import validate_climate_swing_mode import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL -from esphome.core import ID +from esphome.const import ( + CONF_ID, + CONF_SUPPORTED_SWING_MODES, + CONF_TEMPERATURE, + CONF_UART_ID, + CONF_UPDATE_INTERVAL, +) +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType +from . import ( + CONF_MITSUBISHI_CN105_ID, + DOMAIN, + MITSUBISHI_CN105_DEVICE_SCHEMA, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. DEPENDENCIES = ["uart"] AUTO_LOAD = ["climate"] -CODEOWNERS = ["@crnjan"] +_LOGGER = logging.getLogger(__name__) + +# Deprecated legacy climate-owned hub option. Remove in 2027.2.0. CONF_CURRENT_TEMPERATURE_MIN_INTERVAL = "current_temperature_min_interval" - -mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +CONF_LEGACY_MITSUBISHI_CN105_ID = "legacy_mitsubishi_cn105_id" MitsubishiCN105Climate = mitsubishi_ns.class_( "MitsubishiCN105Climate", climate.Climate, cg.Component, - uart.UARTDevice, + cg.Parented.template(MitsubishiCN105Component), ) -SetRemoteTemperatureAction = mitsubishi_ns.class_( - "SetRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacySetRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacySetRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -ClearRemoteTemperatureAction = mitsubishi_ns.class_( - "ClearRemoteTemperatureAction", +# Legacy climate action compatibility. Remove in 2027.2.0. +LegacyClearRemoteTemperatureAction = mitsubishi_ns.class_( + "LegacyClearRemoteTemperatureAction", automation.Action, cg.Parented.template(MitsubishiCN105Climate), ) -CONFIG_SCHEMA = ( - climate.climate_schema(MitsubishiCN105Climate) - .extend(uart.UART_DEVICE_SCHEMA) + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _has_top_level_hub_config() -> bool: + return DOMAIN in (CORE.raw_config or {}) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _prepare_legacy_hub_config(config: ConfigType) -> ConfigType: + _LOGGER.warning( + "Defining 'climate.mitsubishi_cn105' without a top-level '%s:' hub is " + "deprecated. Declare '%s:' and reference it with '%s:' instead. Will " + "be removed in ESPHome 2027.2.0.", + DOMAIN, + DOMAIN, + CONF_MITSUBISHI_CN105_ID, + ) + + # Add the hidden hub declaration only for legacy climate-owned configs, + # so normal auto-ID resolution does not see it as a top-level hub. + config[CONF_LEGACY_MITSUBISHI_CN105_ID] = cv.declare_id(MitsubishiCN105Component)( + None + ) + return config + + +_BASE_SCHEMA = climate.climate_schema(MitsubishiCN105Climate).extend( + { + cv.Optional( + CONF_SUPPORTED_SWING_MODES, default="OFF" + ): validate_climate_swing_mode, + } +) + +_HUB_SCHEMA = _BASE_SCHEMA.extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + +# Hub options accepted in the legacy climate-owned configuration. When a +# top-level hub exists, leaving these on the climate is always a migration +# mistake and the generic schema error does not explain where they belong. +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_HUB_KEYS = ( + CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, + CONF_UART_ID, + CONF_UPDATE_INTERVAL, +) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _validate_no_legacy_hub_keys(config: ConfigType) -> ConfigType: + legacy_keys = [key for key in _LEGACY_HUB_KEYS if key in config] + if not legacy_keys: + return config + + keys = ", ".join(f"'{key}'" for key in legacy_keys) + message = f"{keys} must be moved under the top-level '{DOMAIN}:' block" + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in legacy_keys: + message += ( + f"; rename '{CONF_CURRENT_TEMPERATURE_MIN_INTERVAL}' to " + "'telemetry_request_min_interval' there" + ) + raise cv.Invalid(message) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +_LEGACY_SCHEMA = ( + _BASE_SCHEMA.extend(uart.UART_DEVICE_SCHEMA) .extend( { - cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval, - cv.Optional( - CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s" - ): cv.update_interval, + cv.Optional(CONF_CURRENT_TEMPERATURE_MIN_INTERVAL): cv.update_interval, + cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval, } ) + .add_extra(_prepare_legacy_hub_config) ) -FINAL_VALIDATE_SCHEMA = cv.All( + +@schema_extractor("schema") +def CONFIG_SCHEMA(config: ConfigType) -> ConfigType: + if config is SCHEMA_EXTRACT: + return _HUB_SCHEMA + if CONF_MITSUBISHI_CN105_ID in config or _has_top_level_hub_config(): + return _HUB_SCHEMA(_validate_no_legacy_hub_keys(config)) + return _LEGACY_SCHEMA(config) + + +# Legacy climate-owned hub compatibility. Remove in 2027.2.0. +def _legacy_final_validate(config: ConfigType) -> None: + if CONF_MITSUBISHI_CN105_ID in config: + return + uart.final_validate_device_schema( - "mitsubishi_cn105", + DOMAIN, require_rx=True, require_tx=True, data_bits=8, parity="EVEN", stop_bits=1, - ) -) + )(config) + + +FINAL_VALIDATE_SCHEMA = _legacy_final_validate async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - cg.add( - var.set_current_temperature_min_interval( - config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] - ) - ) - - -@automation.register_action( - "climate.mitsubishi_cn105.set_remote_temperature", - SetRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - cv.Required(CONF_TEMPERATURE): cv.templatable( - cv.All( - cv.temperature, - cv.Range(min=8.0, max=39.5), + climate_config = config.copy() + # update_interval configures the protocol hub, not the climate entity. + climate_config.pop(CONF_UPDATE_INTERVAL, None) + await cg.register_component(var, climate_config) + if CONF_MITSUBISHI_CN105_ID in config: + await register_mitsubishi_cn105_device(var, config) + else: + # Legacy climate-owned hub compatibility. Remove in 2027.2.0. + parent = cg.new_Pvariable(config[CONF_LEGACY_MITSUBISHI_CN105_ID]) + await cg.register_component(parent, config) + await uart.register_uart_device(parent, config) + if CONF_CURRENT_TEMPERATURE_MIN_INTERVAL in config: + cg.add( + parent.set_telemetry_request_min_interval( + config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL] ) - ), - } - ), + ) + cg.add(var.set_parent(parent)) + cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + cv.Required(CONF_TEMPERATURE): cv.templatable( + cv.All( + cv.temperature, + cv.Range(min=8.0, max=39.5), + ) + ), + } +) + +# Legacy climate action compatibility. Remove in 2027.2.0. +LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), + } +) + + +# Legacy climate action compatibility. Remove in 2027.2.0. +@automation.register_action( + f"climate.{DOMAIN}.set_remote_temperature", + LegacySetRemoteTemperatureAction, + LEGACY_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def set_remote_temperature_action_to_code( +async def legacy_remote_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.set_remote_temperature' action is deprecated. Use " + "'%s.set_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float) cg.add(var.set_temperature(temperature)) - return var +# Legacy climate action compatibility. Remove in 2027.2.0. @automation.register_action( - "climate.mitsubishi_cn105.clear_remote_temperature", - ClearRemoteTemperatureAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate), - } - ), + f"climate.{DOMAIN}.clear_remote_temperature", + LegacyClearRemoteTemperatureAction, + LEGACY_CLEAR_REMOTE_TEMPERATURE_ACTION_SCHEMA, synchronous=True, ) -async def clear_remote_temperature_action_to_code( +async def legacy_clear_temperature_action_to_code( config: ConfigType, action_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + _LOGGER.warning( + "The 'climate.%s.clear_remote_temperature' action is deprecated. Use " + "'%s.clear_remote_temperature' instead. It will be removed in ESPHome " + "2027.2.0.", + DOMAIN, + DOMAIN, + ) var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 2a173997f3..3d30d1a25f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,16 +1,16 @@ +#include "mitsubishi_cn105.h" + #include #include #include #include -#include "mitsubishi_cn105.h" +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.driver"; -static constexpr uint32_t WRITE_TIMEOUT_MS = 2000; - -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; +static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; @@ -25,50 +25,11 @@ static constexpr std::array CONNECT_REQUEST_PAYLOAD = {0xCA, 0x01}; static constexpr uint8_t PACKET_TYPE_STATUS_REQUEST = 0x42; static constexpr uint8_t PACKET_TYPE_STATUS_RESPONSE = 0x62; static constexpr uint8_t STATUS_MSG_SETTINGS = 0x02; -static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03; +static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -static constexpr std::array, 9> PROTOCOL_MODE_MAP = { - std::nullopt, // 0x00 - MitsubishiCN105::Mode::HEAT, // 0x01 - MitsubishiCN105::Mode::DRY, // 0x02 - MitsubishiCN105::Mode::COOL, // 0x03 - std::nullopt, // 0x04 - std::nullopt, // 0x05 - std::nullopt, // 0x06 - MitsubishiCN105::Mode::FAN_ONLY, // 0x07 - MitsubishiCN105::Mode::AUTO // 0x08 -}; - -static constexpr std::array, 7> PROTOCOL_FAN_MODE_MAP = { - MitsubishiCN105::FanMode::AUTO, // 0x00 - MitsubishiCN105::FanMode::QUIET, // 0x01 - MitsubishiCN105::FanMode::SPEED_1, // 0x02 - MitsubishiCN105::FanMode::SPEED_2, // 0x03 - std::nullopt, // 0x04 - MitsubishiCN105::FanMode::SPEED_3, // 0x05 - MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}; - -template -static constexpr std::optional lookup(const std::array, N> &table, uint8_t value) { - return (value < N) ? table[value] : std::nullopt; -} - -template -static constexpr bool reverse_lookup(const std::array, N> &table, T value, uint8_t &placeholder) { - for (size_t i = 0; i < N; ++i) { - const auto &table_value = table[i]; - if (table_value.has_value() && table_value == value) { - placeholder = i; - return true; - } - } - return false; -} - static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -82,34 +43,43 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } bool MitsubishiCN105::update() { - if (const auto start = this->status_update_start_ms_) { - if (this->pending_updates_.any()) { - this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - *start); - this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS); + switch (this->state_) { + case State::DEFERRED_STATUS_REQUEST: + // Defer the next request to a later loop iteration; some units might not respond if a request is sent + // immediately after a response. See https://github.com/esphome/esphome/issues/18099. No minimum RX-to-TX delay + // is enforced. + this->set_state_(State::UPDATING_STATUS); return false; - } - if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) { - this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS); - return false; - } - } + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: + if (this->pending_updates_.any()) { + this->status_update_wait_credit_ms_ = + std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_); + this->set_state_(State::APPLYING_SETTINGS); + return false; + } + if (this->has_timed_out_(this->update_interval_ms_)) { + this->set_state_(State::UPDATING_STATUS); + return false; + } + break; - if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { - this->write_timeout_start_ms_.reset(); - this->frame_parser_.reset(); - this->status_update_wait_credit_ms_ = 0; - this->set_state_(State::READ_TIMEOUT); - return false; + case State::CONNECTING: + case State::UPDATING_STATUS: + case State::APPLYING_SETTINGS: + if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) { + this->set_state_(State::READ_TIMEOUT); + return false; + } + break; + + default: + break; } return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) { @@ -138,12 +108,14 @@ bool MitsubishiCN105::should_transition(State from, State to) { return from == State::CONNECTING; case State::UPDATING_STATUS: - return from == State::CONNECTED || from == State::STATUS_UPDATED || - from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; + return from == State::DEFERRED_STATUS_REQUEST || from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; case State::STATUS_UPDATED: return from == State::UPDATING_STATUS; + case State::DEFERRED_STATUS_REQUEST: + return from == State::CONNECTED || from == State::STATUS_UPDATED; + case State::SCHEDULE_NEXT_STATUS_UPDATE: return from == State::STATUS_UPDATED || from == State::SETTINGS_APPLIED; @@ -151,7 +123,7 @@ bool MitsubishiCN105::should_transition(State from, State to) { return from == State::SCHEDULE_NEXT_STATUS_UPDATE; case State::APPLYING_SETTINGS: - return from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE || from == State::STATUS_UPDATED; + return from == State::WAITING_FOR_SCHEDULED_STATUS_UPDATE; case State::SETTINGS_APPLIED: return from == State::APPLYING_SETTINGS; @@ -159,9 +131,10 @@ bool MitsubishiCN105::should_transition(State from, State to) { case State::READ_TIMEOUT: return from == State::UPDATING_STATUS || from == State::APPLYING_SETTINGS || from == State::CONNECTING; - default: + case State::NOT_CONNECTED: return false; } + return false; } void MitsubishiCN105::did_transition_(State to) { @@ -171,9 +144,8 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::CONNECTED: - this->write_timeout_start_ms_.reset(); this->current_status_msg_type_ = STATUS_MSG_SETTINGS; - this->set_state_(State::UPDATING_STATUS); + this->set_state_(State::DEFERRED_STATUS_REQUEST); break; case State::UPDATING_STATUS: @@ -181,12 +153,14 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::STATUS_UPDATED: { - this->write_timeout_start_ms_.reset(); - if (this->pending_updates_.any() && this->is_status_initialized()) { - this->set_state_(State::APPLYING_SETTINGS); - } else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) { - this->current_status_msg_type_ = STATUS_MSG_ROOM_TEMP; - this->set_state_(State::UPDATING_STATUS); + // When present, pending settings are applied from WAITING_FOR_SCHEDULED_STATUS_UPDATE during the next update(), + // deferring transmission to a later loop iteration; some units might not respond if a request is sent + // immediately after a response, causing the request to time out. + const bool should_apply_pending_settings = this->pending_updates_.any() && this->is_status_initialized(); + if (!should_apply_pending_settings && this->current_status_msg_type_ == STATUS_MSG_SETTINGS && + this->should_request_telemetry_()) { + this->current_status_msg_type_ = STATUS_MSG_TELEMETRY; + this->set_state_(State::DEFERRED_STATUS_REQUEST); } else { this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); } @@ -194,7 +168,7 @@ void MitsubishiCN105::did_transition_(State to) { } case State::SCHEDULE_NEXT_STATUS_UPDATE: - this->status_update_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; + this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_; this->status_update_wait_credit_ms_ = 0; this->current_status_msg_type_ = STATUS_MSG_SETTINGS; this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); @@ -205,47 +179,45 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::SETTINGS_APPLIED: - this->write_timeout_start_ms_.reset(); this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE); break; case State::READ_TIMEOUT: + this->frame_parser_.reset(); + this->status_update_wait_credit_ms_ = 0; this->set_state_(State::CONNECTING); break; - default: + case State::NOT_CONNECTED: + case State::DEFERRED_STATUS_REQUEST: + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: break; } } -bool MitsubishiCN105::should_request_room_temperature_() const { - if (!this->is_room_temperature_enabled()) { +bool MitsubishiCN105::should_request_telemetry_() const { + if (!this->is_telemetry_polling_enabled()) { return false; } - if (!this->last_room_temperature_update_ms_.has_value()) { + if (!this->last_telemetry_update_ms_.has_value()) { return true; } - return (get_loop_time_ms() - *this->last_room_temperature_update_ms_) >= this->room_temperature_min_interval_ms_; + return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } -void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - FrameParser::dump_buffer_vv("TX", packet, len); - this->device_.write_array(packet, len); - this->write_timeout_start_ms_ = get_loop_time_ms(); +void MitsubishiCN105::send_packet_(std::span packet) { + FrameParser::dump_buffer_vv("TX", packet.data(), packet.size()); + this->device_.write_array(packet.data(), packet.size()); + this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { - std::array payload = {this->current_status_msg_type_}; + std::array payload{this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } -void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) { - this->status_update_start_ms_.reset(); - this->set_state_(state); -} - bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) { switch (type) { case PACKET_TYPE_CONNECT_RESPONSE: @@ -281,11 +253,12 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) this->set_state_(State::STATUS_UPDATED); } - bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || - previous.fan_mode != this->status_.fan_mode || - previous.target_temperature != this->status_.target_temperature; + bool changed = + previous.power_on != this->status_.power_on || previous.mode != this->status_.mode || + previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature || + previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode; - if (this->is_room_temperature_enabled()) { + if (this->is_telemetry_polling_enabled()) { changed |= previous.room_temperature != this->status_.room_temperature; } @@ -293,12 +266,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: - return this->parse_status_settings_(payload, len); + if (!decoder.decode_settings(this->status_)) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + return true; - case STATUS_MSG_ROOM_TEMP: - return this->parse_status_room_temperature_(payload, len); + case STATUS_MSG_TELEMETRY: + if (!decoder.decode_room_temperature(this->status_)) { + ESP_LOGVV(TAG, "RX telemetry payload too short"); + return false; + } + this->last_telemetry_update_ms_ = get_loop_time_ms(); + return true; default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -306,45 +289,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay } } -bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { - if (len <= 10) { - ESP_LOGVV(TAG, "RX settings payload too short"); - return false; - } - - if (!this->pending_updates_.contains(UpdateFlag::POWER)) { - this->status_.power_on = payload[2] != 0; - } - - this->use_temperature_encoding_b_ = payload[10] != 0; - if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); - } - - if (!this->pending_updates_.contains(UpdateFlag::MODE)) { - const bool i_see = payload[3] > 0x08; - this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN); - } - - return true; -} - -bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, size_t len) { - if (len <= 5) { - ESP_LOGVV(TAG, "RX room temperature payload too short"); - return false; - } - - this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_room_temperature_update_ms_ = get_loop_time_ms(); - - return true; -} - void MitsubishiCN105::set_remote_temperature(float temperature) { if (std::isnan(temperature)) { ESP_LOGD(TAG, "Ignoring NaN remote temperature"); @@ -363,12 +307,12 @@ void MitsubishiCN105::clear_remote_temperature() { void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) { this->remote_temperature_half_deg_ = temperature_half_deg; - this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Remote::ID); } void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; - this->pending_updates_.set(UpdateFlag::POWER); + this->pending_updates_.set(Property::Power::ID); } void MitsubishiCN105::set_target_temperature(float target_temperature) { @@ -377,71 +321,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { return; } this->status_.target_temperature = target_temperature; - this->pending_updates_.set(UpdateFlag::TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Target::ID); } void MitsubishiCN105::set_mode(Mode mode) { - uint8_t placeholder; - if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) { - ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); - return; + if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } - this->status_.mode = mode; - this->pending_updates_.set(UpdateFlag::MODE); } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - uint8_t placeholder; - if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) { - ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); - return; + if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast(fan_mode)); + } +} + +void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { + if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast(vane_mode)); + } +} + +void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { + if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast(wide_vane_mode)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::apply_settings_() { std::array payload{}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; // Apply all other pending settings first; handle REMOTE_TEMPERATURE last - if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) { - payload[0] = 0x07; - if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) { - payload[3] = 0x80; - } else { - payload[1] = 0x01; - payload[2] = static_cast(this->remote_temperature_half_deg_ - 16); - payload[3] = static_cast(this->remote_temperature_half_deg_ + 128); - } - this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE); + if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { + encoder.encode_remote_temperature(this->remote_temperature_half_deg_); } else { - payload[0] = 0x01; - if (this->pending_updates_.contains(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); - } else { - payload[5] = - static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); - } - } - - if (this->pending_updates_.contains(UpdateFlag::MODE) && - reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); @@ -459,6 +374,8 @@ const LogString *MitsubishiCN105::state_to_string(State state) { return LOG_STR("UpdatingStatus"); case State::STATUS_UPDATED: return LOG_STR("StatusUpdated"); + case State::DEFERRED_STATUS_REQUEST: + return LOG_STR("DeferredStatusRequest"); case State::SCHEDULE_NEXT_STATUS_UPDATE: return LOG_STR("ScheduleNextStatusUpdate"); case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 52b78efccd..0fee90dfc1 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,9 +1,12 @@ #pragma once -#include #include "esphome/components/uart/uart.h" #include "esphome/core/finite_set_mask.h" +#include +#include +#include + namespace esphome::mitsubishi_cn105 { uint32_t get_loop_time_ms(); @@ -29,12 +32,36 @@ class MitsubishiCN105 { UNKNOWN, }; + enum class VaneMode : uint8_t { + AUTO, + POSITION_1, + POSITION_2, + POSITION_3, + POSITION_4, + POSITION_5, + SWING, + UNKNOWN, + }; + + enum class WideVaneMode : uint8_t { + FAR_LEFT, + LEFT, + CENTER, + RIGHT, + FAR_RIGHT, + LEFT_RIGHT, + SWING, + UNKNOWN, + }; + struct Status { - bool power_on{false}; float target_temperature{NAN}; + float room_temperature{NAN}; + bool power_on{false}; Mode mode{Mode::UNKNOWN}; FanMode fan_mode{FanMode::UNKNOWN}; - float room_temperature{NAN}; + VaneMode vane_mode{VaneMode::UNKNOWN}; + WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN}; }; explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} @@ -45,22 +72,25 @@ class MitsubishiCN105 { uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } - uint32_t get_room_temperature_min_interval() const { return this->room_temperature_min_interval_ms_; } - bool is_room_temperature_enabled() const { return this->room_temperature_min_interval_ms_ != SCHEDULER_DONT_RUN; } - void set_room_temperature_min_interval(uint32_t interval_ms) { - this->room_temperature_min_interval_ms_ = interval_ms; + uint32_t get_telemetry_request_min_interval() const { return this->telemetry_request_min_interval_ms_; } + bool is_telemetry_polling_enabled() const { return this->telemetry_request_min_interval_ms_ != SCHEDULER_DONT_RUN; } + void set_telemetry_request_min_interval(uint32_t interval_ms) { + this->telemetry_request_min_interval_ms_ = interval_ms; } const Status &status() const { return this->status_; } bool is_status_initialized() const { - return this->is_room_temperature_enabled() ? !std::isnan(this->status_.room_temperature) - : !std::isnan(this->status_.target_temperature); + return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature) + : !std::isnan(this->status_.target_temperature); } + bool is_temperature_encoding_b() const { return this->property_context_.use_temperature_encoding_b; } void set_power(bool power_on); void set_target_temperature(float target_temperature); void set_mode(Mode mode); void set_fan_mode(FanMode fan_mode); + void set_vane_mode(VaneMode vane_mode); + void set_wide_vane_mode(WideVaneMode mode); void set_remote_temperature(float temperature); void clear_remote_temperature(); @@ -71,6 +101,7 @@ class MitsubishiCN105 { CONNECTED, UPDATING_STATUS, STATUS_UPDATED, + DEFERRED_STATUS_REQUEST, SCHEDULE_NEXT_STATUS_UPDATE, WAITING_FOR_SCHEDULED_STATUS_UPDATE, APPLYING_SETTINGS, @@ -93,56 +124,64 @@ class MitsubishiCN105 { uint8_t read_pos_{0}; }; - enum class UpdateFlag : uint8_t { + enum class PropertyId : uint8_t { TEMPERATURE = 0, POWER = 1, MODE = 2, FAN = 3, - REMOTE_TEMPERATURE = 4, + VANE = 4, + WIDE_VANE = 5, + REMOTE_TEMPERATURE = 6 }; struct UpdateFlags { - template void set(Flags... flags) { (this->mask_.insert(flags), ...); } - template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + void set(PropertyId id) { this->mask_.insert(id); } + void clear(PropertyId id) { this->mask_.erase(id); } bool any() const { return !this->mask_.empty(); } - bool contains(UpdateFlag flag) const { return this->mask_.count(flag); } - bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); } + bool contains(PropertyId id) const { return this->mask_.count(id); } + bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); } protected: using Mask = - FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; - + FiniteSetMask(PropertyId::REMOTE_TEMPERATURE) + 1>>; Mask mask_; }; + struct PropertyContext { + bool use_temperature_encoding_b{false}; + bool set_wide_vane_high_bit{false}; + }; + + friend struct Property; + void set_state_(State new_state); void did_transition_(State to); bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); - bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_room_temperature_(const uint8_t *payload, size_t len); - void send_packet_(const uint8_t *packet, size_t len); + void send_packet_(std::span packet); void update_status_(); - void cancel_waiting_and_transition_to_(State state); - bool should_request_room_temperature_() const; + bool should_request_telemetry_() const; void apply_settings_(); + bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); - template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } static bool should_transition(State from, State to); static const LogString *state_to_string(State state); uart::UARTDevice &device_; + // Default 1s; legacy climate-owned hub compatibility relies on this when update_interval is omitted. + // Remove legacy note in 2027.2.0. uint32_t update_interval_ms_{1000}; uint32_t status_update_wait_credit_ms_{0}; - uint32_t room_temperature_min_interval_ms_{60000}; - std::optional write_timeout_start_ms_; - std::optional status_update_start_ms_; - std::optional last_room_temperature_update_ms_; + uint32_t operation_start_ms_{0}; + // Default 60s; legacy climate-owned hub compatibility relies on this when current_temperature_min_interval is + // omitted. Remove legacy note in 2027.2.0. + uint32_t telemetry_request_min_interval_ms_{60000}; + std::optional last_telemetry_update_ms_; Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; - bool use_temperature_encoding_b_{false}; + PropertyContext property_context_; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 284339e57f..53b8c21de6 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,5 +1,5 @@ -#include #include "mitsubishi_cn105_climate.h" + #include "esphome/core/log.h" namespace esphome::mitsubishi_cn105 { @@ -52,23 +52,13 @@ static constexpr std::optional reverse_map_lookup(const std::arrayhp_.is_room_temperature_enabled()) { - ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms", - this->hp_.get_room_temperature_min_interval()); - } else { - ESP_LOGCONFIG(TAG, " Current temperature: disabled"); - } - ESP_LOGCONFIG(TAG, - " Update interval: %" PRIu32 " ms\n" - " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", - this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), - LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); + ESP_LOGCONFIG(TAG, " Temperature unit: °%c", + this->parent_->get_temperature_mapping().get_use_fahrenheit() ? 'F' : 'C'); } -void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } - -void MitsubishiCN105Climate::loop() { - if (this->hp_.update()) { +void MitsubishiCN105Climate::setup() { + this->parent_->add_on_status_callback([this]() { this->apply_values_(); }); + if (this->parent_->is_status_initialized()) { this->apply_values_(); } } @@ -84,13 +74,17 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.add_supported_fan_mode(p.second); } - traits.set_visual_min_temperature(16.0f); - traits.set_visual_max_temperature(31.0f); + traits.set_supported_swing_modes(this->swing_mode_manager_.supported_swing_modes()); + + const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit(); + traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS); + traits.set_visual_min_temperature(use_fahrenheit ? 61.0f : 16.0f); + traits.set_visual_max_temperature(use_fahrenheit ? 88.0f : 31.0f); traits.set_visual_temperature_step(1.0f); - if (this->hp_.is_room_temperature_enabled()) { + if (this->parent_->is_telemetry_polling_enabled()) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); - traits.set_visual_current_temperature_step(0.5f); + traits.set_visual_current_temperature_step(use_fahrenheit ? 1.0f : 0.5f); } return traits; @@ -98,34 +92,41 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { if (const auto target_temperature = call.get_target_temperature()) { - this->hp_.set_target_temperature(*target_temperature); + this->parent_->set_target_temperature(this->parent_->get_temperature_mapping().to_mitsubishi(*target_temperature)); } if (const auto mode = call.get_mode()) { if (*mode == climate::CLIMATE_MODE_OFF) { - this->hp_.set_power(false); + this->parent_->set_power(false); } else if (const auto mapped = reverse_map_lookup(MODE_MAP, *mode)) { - this->hp_.set_power(true); - this->hp_.set_mode(*mapped); + this->parent_->set_power(true); + this->parent_->set_mode(*mapped); } } if (const auto fan_mode = reverse_map_lookup(FAN_MODE_MAP, call.get_fan_mode())) { - this->hp_.set_fan_mode(*fan_mode); + this->parent_->set_fan_mode(*fan_mode); } - if (this->hp_.is_status_initialized()) { - this->apply_values_(); + if (const auto swing_mode = call.get_swing_mode()) { + if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) { + this->parent_->set_vane_mode(*vane); + } + if (const auto wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) { + this->parent_->set_wide_vane_mode(*wide); + } } + + this->parent_->publish_status(); } void MitsubishiCN105Climate::apply_values_() { - const auto &status = this->hp_.status(); + const auto &status = this->parent_->status(); - this->target_temperature = status.target_temperature; + this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature); - if (this->hp_.is_room_temperature_enabled()) { - this->current_temperature = status.room_temperature; + if (this->parent_->is_telemetry_polling_enabled()) { + this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature); } if (status.power_on) { @@ -143,7 +144,39 @@ void MitsubishiCN105Climate::apply_values_() { ESP_LOGD(TAG, "Unable to map fan mode"); } + if (const auto swing_mode = + this->swing_mode_manager_.update_and_get_swing_mode(status.vane_mode, status.wide_vane_mode)) { + this->swing_mode = *swing_mode; + } + this->publish_state(); } +void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) { + climate::ClimateSwingModeMask supported_swing_modes; + switch (mode) { + case climate::CLIMATE_SWING_VERTICAL: + supported_swing_modes.insert(climate::CLIMATE_SWING_OFF); + supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL); + break; + + case climate::CLIMATE_SWING_HORIZONTAL: + supported_swing_modes.insert(climate::CLIMATE_SWING_OFF); + supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL); + break; + + case climate::CLIMATE_SWING_BOTH: + supported_swing_modes.insert(climate::CLIMATE_SWING_OFF); + supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL); + supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL); + supported_swing_modes.insert(climate::CLIMATE_SWING_BOTH); + break; + + case climate::CLIMATE_SWING_OFF: + default: + break; + } + this->swing_mode_manager_.set_supported_swing_modes(supported_swing_modes); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h index e09158bfcf..cea76278ab 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -1,46 +1,48 @@ #pragma once +#include "mitsubishi_cn105_component.h" +#include "mitsubishi_cn105.h" + #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/components/climate/climate.h" -#include "esphome/components/uart/uart.h" -#include "mitsubishi_cn105.h" +#include "mitsubishi_cn105_swing_mode_manager.h" namespace esphome::mitsubishi_cn105 { -class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice { +class MitsubishiCN105Climate final : public climate::Climate, + public Component, + public Parented { public: - explicit MitsubishiCN105Climate() : hp_(*this) {} - void setup() override; - void loop() override; void dump_config() override; climate::ClimateTraits traits() override; void control(const climate::ClimateCall &call) override; - void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } - void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); } - - void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } - void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + void set_supported_swing_mode(climate::ClimateSwingMode mode); + // Legacy climate action compatibility. Remove in 2027.2.0. + void set_remote_temperature(float temperature) { this->parent_->set_remote_temperature(temperature); } + void clear_remote_temperature() { this->parent_->clear_remote_temperature(); } protected: void apply_values_(); - MitsubishiCN105 hp_; + SwingModeManager swing_mode_manager_; }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class SetRemoteTemperatureAction : public Action, public Parented { +class LegacySetRemoteTemperatureAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(float, temperature) void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); } }; +// Legacy climate action compatibility. Remove in 2027.2.0. template -class ClearRemoteTemperatureAction : public Action, public Parented { +class LegacyClearRemoteTemperatureAction final : public Action, public Parented { public: void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); } }; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp new file mode 100644 index 0000000000..e2a6ee05af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -0,0 +1,48 @@ +#include "mitsubishi_cn105_component.h" + +#include "esphome/core/log.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105"; + +void MitsubishiCN105Component::dump_config() { + ESP_LOGCONFIG(TAG, "Mitsubishi CN105:"); + if (this->hp_.is_telemetry_polling_enabled()) { + ESP_LOGCONFIG(TAG, " Telemetry polling min interval: %" PRIu32 " ms", + this->hp_.get_telemetry_request_min_interval()); + } else { + ESP_LOGCONFIG(TAG, " Telemetry polling: DISABLED"); + } + ESP_LOGCONFIG(TAG, + " Update interval: %" PRIu32 " ms\n" + " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", + this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), + LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); +} + +void MitsubishiCN105Component::setup() { this->hp_.initialize(); } + +void MitsubishiCN105Component::loop() { + if (this->hp_.update()) { + // Encoding A only supports whole °C values and cannot represent native °F setpoints accurately. + // See https://github.com/esphome/esphome/pull/15488#issuecomment-5268304343 + if (this->temperature_mapping_.get_use_fahrenheit() && !this->hp_.is_temperature_encoding_b()) { + ESP_LOGE(TAG, "Unit reports encoding A, which cannot accurately convert °F setpoints; disable 'use_fahrenheit'"); + this->mark_failed(); + return; + } + this->notify_status_listeners_(); + } +} + +void VaneCall::perform() { + if (const auto &direction = this->vertical.get_direction(); direction.has_value()) { + this->parent_->set_vane_mode(static_cast(*direction)); + } + this->parent_->publish_status(); +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h new file mode 100644 index 0000000000..aa9bfe0d8c --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -0,0 +1,139 @@ +#pragma once + +#include "mitsubishi_cn105.h" + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart.h" + +#include +#include +#include +#include + +namespace esphome::mitsubishi_cn105 { + +struct TemperatureMapping { + float to_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + const int fahrenheit = std::clamp(static_cast(std::round(value)), 61, 88); + return 0.5f * (fahrenheit - 28 + (fahrenheit > 68) - (fahrenheit < 68)); + } + + float from_mitsubishi(float value) const { + if (!this->use_fahrenheit_) { + return value; + } + if (value < 16.0f || value > 30.5f) { + return celsius_to_fahrenheit(value); + } + const int mitsubishi_half_degrees = static_cast(std::round(value * 2.0f)); + return mitsubishi_half_degrees + 29 - (mitsubishi_half_degrees >= 40) - (mitsubishi_half_degrees > 40); + } + + bool get_use_fahrenheit() const { return this->use_fahrenheit_; } + void set_use_fahrenheit(bool value) { this->use_fahrenheit_ = value; } + + protected: + bool use_fahrenheit_{false}; +}; + +enum VerticalVaneMode : uint8_t { + VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), + VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), + VERTICAL_VANE_MODE_POSITION_2 = static_cast(MitsubishiCN105::VaneMode::POSITION_2), + VERTICAL_VANE_MODE_POSITION_3 = static_cast(MitsubishiCN105::VaneMode::POSITION_3), + VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), + VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), + VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), + VERTICAL_VANE_MODE_UNKNOWN = static_cast(MitsubishiCN105::VaneMode::UNKNOWN), +}; + +struct VaneState { + struct Vertical { + VerticalVaneMode direction; + }; + + Vertical vertical; +}; + +class MitsubishiCN105Component; + +struct VaneCall { + struct Vertical { + void set_direction(VerticalVaneMode direction) { this->direction_ = direction; } + const std::optional &get_direction() const { return this->direction_; } + + protected: + std::optional direction_; + }; + + explicit VaneCall(MitsubishiCN105Component *parent) : parent_(parent) {} + + Vertical vertical; + + void perform(); + + protected: + MitsubishiCN105Component *parent_; +}; + +class MitsubishiCN105Component final : public Component, public uart::UARTDevice { + public: + explicit MitsubishiCN105Component() : hp_(*this) {} + + void setup() override; + void loop() override; + void dump_config() override; + + void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); } + void set_telemetry_request_min_interval(uint32_t ms) { this->hp_.set_telemetry_request_min_interval(ms); } + void set_use_fahrenheit(bool value) { this->temperature_mapping_.set_use_fahrenheit(value); } + + void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); } + void clear_remote_temperature() { this->hp_.clear_remote_temperature(); } + + void set_power(bool power_on) { this->hp_.set_power(power_on); } + void set_target_temperature(float target_temperature) { this->hp_.set_target_temperature(target_temperature); } + void set_mode(MitsubishiCN105::Mode mode) { this->hp_.set_mode(mode); } + void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } + void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } + void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + VaneCall make_vane_call() { return VaneCall(this); } + + const MitsubishiCN105::Status &status() const { return this->hp_.status(); } + bool is_status_initialized() const { return this->hp_.is_status_initialized(); } + bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + const TemperatureMapping &get_temperature_mapping() const { return this->temperature_mapping_; } + + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + + template void add_on_vane_state_callback(F &&callback) { + this->vane_state_callback_.add(std::forward(callback)); + } + + void publish_status() { + if (this->is_status_initialized()) { + this->notify_status_listeners_(); + } + } + + protected: + void notify_status_listeners_() { + this->status_callback_.call(); + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); + } + + MitsubishiCN105 hp_; + TemperatureMapping temperature_mapping_; + CallbackManager status_callback_; + LazyCallbackManager vane_state_callback_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h new file mode 100644 index 0000000000..1f5faf61af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +template struct LookupMap { + using value_type = decltype(Unknown); + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == Unknown) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +struct Property { + using PropertyId = MitsubishiCN105::PropertyId; + using Status = MitsubishiCN105::Status; + using PropertyContext = MitsubishiCN105::PropertyContext; + + struct Power { + static constexpr auto ID = PropertyId::POWER; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.power_on = payload[2] != 0; + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x01; + payload[3] = status.power_on ? 0x01 : 0x00; + } + }; + + struct Temperature { + struct Target { + static constexpr auto ID = PropertyId::TEMPERATURE; + static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.use_temperature_encoding_b = payload[10] != 0; + } + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x04; + if (ctx.use_temperature_encoding_b) { + payload[14] = static_cast(std::round(status.target_temperature * 2.0f) + 128); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature)); + } + } + }; + + struct Room { + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.room_temperature = Temperature::decode(payload[2], payload[5], 10); + } + }; + + struct Remote { + static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE; + + static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) { + if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) { + payload[3] = 0x80; + } else { + payload[1] = 0x01; + payload[2] = static_cast(remote_temperature_half_deg - 16); + payload[3] = static_cast(remote_temperature_half_deg + 128); + } + } + }; + + protected: + static constexpr float decode(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; + } + }; + + template struct Lookup { + using Value = std::remove_cvref_t().*Field)>; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx)); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + uint8_t raw; + if (Derived::MAP.reverse_lookup(status.*Field, raw)) { + Derived::encode_raw(payload, raw, ctx); + } + } + + template static bool validate_and_set(Value value, Status &status, Mask &mask) { + uint8_t raw; + if (!Derived::MAP.reverse_lookup(value, raw)) { + return false; + } + status.*Field = value; + mask.set(Derived::ID); + return true; + } + + private: + friend Derived; + constexpr Lookup() = default; + }; + + struct Mode : Lookup { + static constexpr auto ID = PropertyId::MODE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { + const bool i_see = payload[3] > 0x08; + return payload[3] - (i_see ? 0x08 : 0); + } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x02; + payload[4] = raw; + } + }; + + struct FanMode : Lookup { + static constexpr auto ID = PropertyId::FAN; + static constexpr auto MAP = make_map({ + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x08; + payload[6] = raw; + } + }; + + struct VaneMode : Lookup { + static constexpr auto ID = PropertyId::VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x10; + payload[7] = raw; + } + }; + + struct WideVaneMode : Lookup { + static constexpr auto ID = PropertyId::WIDE_VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C + }); + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80; + } + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) { + payload[2] |= 0x01; + payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw; + } + }; + + template struct Decoder { + const std::span payload; + PropertyContext &context; + const Mask &pending_writes; + + bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) { + if (this->payload.size() <= 10) { + return false; + } + this->decode_(status); + return true; + } + + bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) { + if (this->payload.size() <= 5) { + return false; + } + this->decode_(status); + return true; + } + + protected: + template ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) { + T::decode_context(this->context, this->payload.data()); + if constexpr (requires { T::ID; }) { + if (this->pending_writes.contains(T::ID)) { + return; + } + } + T::decode(out, this->payload.data(), this->context); + } + + template void ESPHOME_ALWAYS_INLINE decode_(Out &out) { + (this->decode_one_(out), ...); + } + }; + + template struct Encoder { + uint8_t *payload; + const PropertyContext &context; + Mask &pending_writes; + + void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) { + this->payload[0] = 0x01; + this->encode_and_clear_(status); + } + + void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) { + this->payload[0] = 0x07; + this->encode_and_clear_(remote_temperature_half_deg); + } + + protected: + template void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) { + (this->encode_one_(in), ...); + (this->pending_writes.clear(T::ID), ...); + } + + template void encode_one_(const In &in) { + if (this->pending_writes.contains(T::ID)) { + T::encode(this->payload, in, this->context); + } + } + }; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h new file mode 100644 index 0000000000..20f54f0bbb --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h @@ -0,0 +1,86 @@ +#pragma once + +#include + +#include "esphome/components/climate/climate.h" +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +class SwingModeManager final { + public: + const climate::ClimateSwingModeMask &supported_swing_modes() const { return this->supported_swing_modes_; } + void set_supported_swing_modes(const climate::ClimateSwingModeMask &supported_swing_modes) { + this->supported_swing_modes_ = supported_swing_modes; + } + + std::optional vane_from(climate::ClimateSwingMode swing_mode) const { + if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + return std::nullopt; + } + + switch (swing_mode) { + case climate::CLIMATE_SWING_BOTH: + case climate::CLIMATE_SWING_VERTICAL: + return MitsubishiCN105::VaneMode::SWING; + default: + return this->last_non_swing_vane_mode_; + } + } + + std::optional wide_vane_from(climate::ClimateSwingMode swing_mode) const { + if (!this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + return std::nullopt; + } + + switch (swing_mode) { + case climate::CLIMATE_SWING_BOTH: + case climate::CLIMATE_SWING_HORIZONTAL: + return MitsubishiCN105::WideVaneMode::SWING; + default: + return this->last_non_swing_wide_vane_mode_; + } + } + + std::optional update_and_get_swing_mode(MitsubishiCN105::VaneMode vane_mode, + MitsubishiCN105::WideVaneMode wide_vane_mode) { + if (this->supported_swing_modes_.empty()) { + return std::nullopt; + } + + bool vertical_swinging = false; + bool horizontal_swinging = false; + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { + if (vane_mode == MitsubishiCN105::VaneMode::SWING) { + vertical_swinging = true; + } else if (vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { + this->last_non_swing_vane_mode_ = vane_mode; + } + } + if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { + if (wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) { + horizontal_swinging = true; + } else if (wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) { + this->last_non_swing_wide_vane_mode_ = wide_vane_mode; + } + } + + if (vertical_swinging && horizontal_swinging) { + return climate::CLIMATE_SWING_BOTH; + } + if (vertical_swinging) { + return climate::CLIMATE_SWING_VERTICAL; + } + if (horizontal_swinging) { + return climate::CLIMATE_SWING_HORIZONTAL; + } + return climate::CLIMATE_SWING_OFF; + } + + private: + climate::ClimateSwingModeMask supported_swing_modes_{}; + MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO}; + MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER}; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py new file mode 100644 index 0000000000..4ca12edbb4 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import ( + MITSUBISHI_CN105_DEVICE_SCHEMA, + VERTICAL_VANE_DIRECTIONS, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +DEPENDENCIES = ["mitsubishi_cn105"] + +CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" + +MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( + "MitsubishiCN105VerticalVaneDirectionSelect", + select.Select, + cg.Component, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema( + MitsubishiCN105VerticalVaneDirectionSelect, + icon="mdi:arrow-up-down", + ), + } +).extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION): + var = cg.new_Pvariable(vertical_vane_direction[CONF_ID]) + await cg.register_component(var, vertical_vane_direction) + await select.register_select( + var, + vertical_vane_direction, + options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS], + ) + await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp new file mode 100644 index 0000000000..d703ddbb02 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -0,0 +1,39 @@ +#include "mitsubishi_cn105_vane_select_vertical.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in the hub's __init__.py. +// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based +// Select API, so Python option order and this array must stay aligned. +static constexpr std::array VALUES{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2, + MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, +}; + +void MitsubishiCN105VerticalVaneDirectionSelect::setup() { + this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); }); + if (this->parent_->is_status_initialized()) { + this->publish_vane_state(this->parent_->status().vane_mode); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) { + if (index < VALUES.size()) { + this->parent_->set_vane_mode(VALUES[index]); + this->parent_->publish_status(); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) { + for (size_t i = 0; i < VALUES.size(); ++i) { + if (VALUES[i] == mode) { + this->publish_state(i); + return; + } + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h new file mode 100644 index 0000000000..656b78b487 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../mitsubishi_cn105_component.h" + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105VerticalVaneDirectionSelect final : public select::Select, + public Component, + public Parented { + public: + void setup() override; + void publish_vane_state(MitsubishiCN105::VaneMode mode); + + protected: + void control(size_t index) override; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 59a80d9297..a3746c019a 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import audio, esp32, speaker +from esphome.components import audio, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BITS_PER_SAMPLE, @@ -15,8 +15,11 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) +from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from +from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv +from esphome.types import ConfigType AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] @@ -44,21 +47,11 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), - cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16), } ) -def _set_stream_limits(config): - audio.set_stream_limits( - min_bits_per_sample=16, - max_bits_per_sample=16, - )(config) - - return config - - -def _validate_source_speaker(config): +def _validate_source_speaker(config: ConfigType) -> ConfigType: fconf = fv.full_config.get() # Get ID for the output speaker and add it to the source speakers config to easily inherit properties @@ -67,15 +60,25 @@ def _validate_source_speaker(config): output_speaker_id = fconf.get_config_for_path(path) config[CONF_OUTPUT_SPEAKER] = output_speaker_id + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config) inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config) + audio.final_validate_audio_schema( + "mixer", + audio_device=CONF_OUTPUT_SPEAKER, + sample_rate=config.get(CONF_SAMPLE_RATE), + )(config) + + return config + + +def _validate_output_speaker(config: ConfigType) -> ConfigType: audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, bits_per_sample=config.get(CONF_BITS_PER_SAMPLE), channels=config.get(CONF_NUM_CHANNELS), - sample_rate=config.get(CONF_SAMPLE_RATE), )(config) return config @@ -89,43 +92,43 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SOURCE_SPEAKERS): cv.All( cv.ensure_list(SOURCE_SPEAKER_SCHEMA), cv.Length(min=2, max=8), - [_set_stream_limits], ), + cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True), cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2), cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean, - cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, } ), cv.only_on([PLATFORM_ESP32]), ) FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER), + inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), cv.Schema( { cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker], }, extra=cv.ALLOW_EXTRA, ), - inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER), + _validate_output_speaker, ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER]) + cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS])) cg.add(var.set_output_speaker(spkr)) cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE])) - if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM): - cg.add(var.set_task_stack_in_psram(task_stack_in_psram)) - if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]: - esp32.add_idf_sdkconfig_option( - "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True - ) + if config.get(CONF_TASK_STACK_IN_PSRAM): + cg.add(var.set_task_stack_in_psram(True)) + psram.request_external_task_stack() # Initialize FixedVector with exact count of source speakers cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS]))) @@ -161,7 +164,12 @@ async def to_code(config): ), synchronous=True, ) -async def ducking_set_to_code(config, action_id, template_arg, args): +async def ducking_set_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) decibel_reduction = await cg.templatable( 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.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 1a995a6edf..6128dc3767 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -7,8 +7,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include // esp-audio-libs +#include // esp-audio-libs + #include -#include #include namespace esphome::mixer_speaker { @@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; -static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX; -static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN; - static const char *const TAG = "speaker_mixer"; -// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB -// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014) -// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15) -static const std::array DECIBEL_REDUCTION_TABLE = { - 32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183, - 4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731, - 651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103}; - // Event bits for SourceSpeaker command processing enum SourceSpeakerEventBits : uint32_t { SOURCE_SPEAKER_COMMAND_START = (1 << 0), @@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - int16_t *current_buffer = reinterpret_cast(audio_source->mutable_data()); - - duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_, - &this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_, - this->db_change_per_ducking_step_); + esp_audio_libs::ducking::apply(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck, this->ducking_state_); } return bytes_read; } void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) { - if (this->target_ducking_db_reduction_ != decibel_reduction) { - // Start transition from the previous target (which becomes the new current level) - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - - this->target_ducking_db_reduction_ = decibel_reduction; - - // Calculate the number of intermediate dB steps for the transition timing. - // Subtract 1 because the first step is taken immediately after this calculation. - uint8_t total_ducking_steps = 0; - if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) { - // The dB reduction level is increasing (which results in quieter audio) - total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = 1; - } else { - // The dB reduction level is decreasing (which results in louder audio) - total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1; - this->db_change_per_ducking_step_ = -1; - } - if ((duration > 0) && (total_ducking_steps > 0)) { - this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration); - - this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps; - this->ducking_transition_samples_remaining_ = - this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding - - this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_; - } else { - this->ducking_transition_samples_remaining_ = 0; - this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_; - } - } -} - -void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, - int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining, - uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) { - if (*ducking_transition_samples_remaining > 0) { - // Ducking level is still transitioning - - // Takes the ceiling of input_samples_to_duck/samples_per_ducking_step - uint32_t ducking_steps_in_batch = - input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0); - - for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) { - uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step; - - if (samples_left_in_step == 0) { - samples_left_in_step = samples_per_ducking_step; - } - - uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step); - samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining); - - // Ensure we only point to valid index in the Q15 scaling factor table - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck); - - if (samples_left_in_step - samples_to_duck == 0) { - // After scaling the current samples, we are ready to transition to the next step - *current_ducking_db_reduction += db_change_per_ducking_step; - } - - input_buffer += samples_to_duck; - *ducking_transition_samples_remaining -= samples_to_duck; - input_samples_to_duck -= samples_to_duck; - } - } - - if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) { - // Audio is ducked, but its not in the middle of a transition step - - uint8_t safe_db_reduction_index = - clamp(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1); - int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index]; - - audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck); - } + const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; + esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { @@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() { void MixerSpeaker::dump_config() { ESP_LOGCONFIG(TAG, "Speaker Mixer:\n" - " Number of output channels: %u", - this->output_channels_); + " Number of output channels: %" PRIu8 "\n" + " Output bits per sample: %" PRIu8, + this->output_channels_, this->output_bits_per_sample_); } void MixerSpeaker::setup() { @@ -512,13 +424,8 @@ void MixerSpeaker::loop() { esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { if (!this->audio_stream_info_.has_value()) { - if (stream_info.get_bits_per_sample() != 16) { - // Audio streams that don't have 16 bits per sample are not supported - return ESP_ERR_NOT_SUPPORTED; - } - - this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_, - stream_info.get_sample_rate()); + this->audio_stream_info_ = + audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate()); this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value()); } else { if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) { @@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) { return ESP_OK; } -void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_transfer) { - uint8_t input_channels = input_stream_info.get_channels(); - uint8_t output_channels = output_stream_info.get_channels(); - const uint8_t max_input_channel_index = input_channels - 1; - - if (input_channels == output_channels) { - size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer); - memcpy(output_buffer, input_buffer, bytes_to_copy); - - return; - } - - for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index); - output_buffer[output_channels * frame_index + output_channel_index] = - input_buffer[input_channels * frame_index + input_channel_index]; - } - } -} - -void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix) { - const uint8_t primary_channels = primary_stream_info.get_channels(); - const uint8_t secondary_channels = secondary_stream_info.get_channels(); - const uint8_t output_channels = output_stream_info.get_channels(); - - const uint8_t max_primary_channel_index = primary_channels - 1; - const uint8_t max_secondary_channel_index = secondary_channels - 1; - - for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) { - for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) { - const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index); - const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index]; - - const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index); - const int32_t primary_sample = - static_cast(primary_buffer[frames_index * primary_channels + primary_channel_index]); - - const int32_t added_sample = secondary_sample + primary_sample; - - output_buffer[frames_index * output_channels + output_channel_index] = - static_cast(clamp(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE)); - } - } -} - // NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created void MixerSpeaker::audio_mixer_task(void *params) { MixerSpeaker *this_mixer = static_cast(params); @@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) { uint32_t frames_to_mix = output_frames_free; + const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value(); + const uint8_t output_bps = output_info.get_bits_per_sample() / 8; + const uint8_t output_channels = output_info.get_channels(); + if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) { // Only one speaker has audio data, just copy samples over @@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (active_stream_info.get_sample_rate() == this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + // Speaker's sample rate matches the output speaker's, convert directly into the output buffer const uint32_t frames_available_in_buffer = active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(audio_sources_with_data[0]->data()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::pcm_convert::copy_frames( + audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(), + static_cast(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(), + output_bps, output_channels, frames_to_mix); // Set playback delay for newly contributing source if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { @@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix)); // Update output transfer buffer length and pipeline frame count - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } else { // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker @@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } else { // Speaker has finished writing the current audio, update the stream information and restart the speaker this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_, active_stream_info.get_sample_rate()); this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); this_mixer->output_speaker_->start(); @@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) { speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available()); frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); } - const int16_t *primary_buffer = reinterpret_cast(audio_sources_with_data[0]->data()); + const uint8_t *primary_buffer = audio_sources_with_data[0]->data(); audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - // Mix two streams together + // Mix two streams together at a time, accumulating into the output buffer. for (size_t i = 1; i < audio_sources_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(audio_sources_with_data[i]->data()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + esp_audio_libs::mixer::mix_frames( + primary_buffer, static_cast(primary_stream_info.get_bits_per_sample() / 8), + primary_stream_info.get_channels(), audio_sources_with_data[i]->data(), + static_cast(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8), + speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(), + output_bps, output_channels, frames_to_mix); if (i != audio_sources_with_data.size() - 1) { // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); + primary_buffer = output_transfer_buffer->get_buffer_end(); + primary_stream_info = output_info; } } @@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { } // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index f57bead679..00e89d1782 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,6 +11,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" +#include // esp-audio-libs + #include #include @@ -22,7 +24,8 @@ namespace esphome::mixer_speaker { * - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread * safety * - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker. - * - Audio sent to the SourceSpeaker must have 16 bits per sample. + * - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output + * speaker's bit depth as it is mixed (or copied) into the output buffer. * - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match * the number of channels required for the output speaker. * - In queue mode, the audio sent to the SourceSpeakers can have different sample rates. @@ -41,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; @@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component { void enter_stopping_state_(); void send_command_(uint32_t command_bit, bool wake_loop = false); - /// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually - /// over a specified amount of samples. - /// @param input_buffer buffer with audio samples to be ducked in place - /// @param input_samples_to_duck number of samples to process in ``input_buffer`` - /// @param current_ducking_db_reduction pointer to the current dB reduction - /// @param ducking_transition_samples_remaining pointer to the total number of samples left before the - /// transition is finished - /// @param samples_per_ducking_step total number of samples per ducking step for the transition - /// @param db_change_per_ducking_step the change in dB reduction per step - static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction, - uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step, - int8_t db_change_per_ducking_step); - MixerSpeaker *parent_; std::shared_ptr audio_source_; @@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component { bool pause_state_{false}; - int8_t target_ducking_db_reduction_{0}; - int8_t current_ducking_db_reduction_{0}; - int8_t db_change_per_ducking_step_{1}; - uint32_t ducking_transition_samples_remaining_{0}; - uint32_t samples_per_ducking_step_{0}; + esp_audio_libs::ducking::DuckingState ducking_state_{}; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing @@ -132,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; @@ -143,12 +129,14 @@ class MixerSpeaker : public Component { /// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information /// @param stream_info The calling source speaker's audio stream information - /// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample - /// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream + /// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream /// ESP_OK if the incoming stream is compatible and the mixer task starts esp_err_t start(audio::AudioStreamInfo &stream_info); void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; } + void set_output_bits_per_sample(uint8_t output_bits_per_sample) { + this->output_bits_per_sample_ = output_bits_per_sample; + } void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; } void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } @@ -159,33 +147,6 @@ class MixerSpeaker : public Component { uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); } protected: - /// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels - /// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has - /// less channels, the extra channel input samples are dropped. - /// @param input_buffer - /// @param input_stream_info - /// @param output_buffer - /// @param output_stream_info - /// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer - static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer, - audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer); - - /// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary - /// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number - /// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample - /// overflows. - /// @param primary_buffer samples buffer for the primary stream - /// @param primary_stream_info stream info for the primary stream - /// @param secondary_buffer samples buffer for secondary stream - /// @param secondary_stream_info stream info for the secondary stream - /// @param output_buffer buffer for the mixed samples - /// @param output_stream_info stream info for the output buffer - /// @param frames_to_mix number of frames in the primary and secondary buffers to mix together - static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info, - const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info, - int16_t *output_buffer, audio::AudioStreamInfo output_stream_info, - uint32_t frames_to_mix); - static void audio_mixer_task(void *params); EventGroupHandle_t event_group_{nullptr}; @@ -193,6 +154,7 @@ class MixerSpeaker : public Component { FixedVector source_speakers_; speaker::Speaker *output_speaker_{nullptr}; + uint8_t output_bits_per_sample_; uint8_t output_channels_; bool queue_mode_; bool task_stack_in_psram_{false}; diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index a6330b1cc0..59bdffc114 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -17,6 +17,7 @@ from esphome.const import ( UNIT_CELSIUS, UNIT_MICROTESLA, ) +from esphome.types import ConfigType CODEOWNERS = ["@functionpointer"] DEPENDENCIES = ["i2c"] @@ -52,7 +53,7 @@ CONF_DRDY_PIN = "drdy_pin" CONF_HALLCONF = "hallconf" -def _validate(config): +def _validate(config: ConfigType) -> ConfigType: if config[CONF_TEMPERATURE_COMPENSATION]: for axis in [CONF_X_AXIS, CONF_Y_AXIS, CONF_Z_AXIS]: if axis not in config: @@ -74,7 +75,7 @@ def _validate(config): return config -def mlx90393_axis_schema(): +def mlx90393_axis_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_MICROTESLA, accuracy_decimals=0, @@ -127,7 +128,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 7048302124..2288e8ff84 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -1,3 +1,5 @@ +#ifndef USE_BK72XX + #include "sensor_mlx90393.h" #include "esphome/core/log.h" @@ -270,3 +272,5 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { } } // namespace esphome::mlx90393 + +#endif // USE_BK72XX diff --git a/esphome/components/mlx90393/sensor_mlx90393.h b/esphome/components/mlx90393/sensor_mlx90393.h index 28053216e2..03e78f51cc 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.h +++ b/esphome/components/mlx90393/sensor_mlx90393.h @@ -1,5 +1,7 @@ #pragma once +#ifndef USE_BK72XX + #include #include #include "esphome/components/i2c/i2c.h" @@ -20,7 +22,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; @@ -76,3 +78,5 @@ class MLX90393Cls : public PollingComponent, public i2c::I2CDevice, public MLX90 }; } // namespace esphome::mlx90393 + +#endif // USE_BK72XX 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/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index 6a34c4bdc0..0cf9b95dde 100644 --- a/esphome/components/mlx90614/sensor.py +++ b/esphome/components/mlx90614/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] @@ -47,7 +48,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/mmc5603/sensor.py b/esphome/components/mmc5603/sensor.py index 6d2bafdd0e..a9f240508c 100644 --- a/esphome/components/mmc5603/sensor.py +++ b/esphome/components/mmc5603/sensor.py @@ -15,6 +15,8 @@ from esphome.const import ( UNIT_DEGREES, UNIT_MICROTESLA, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CONF_AUTO_SET_RESET = "auto_set_reset" @@ -65,7 +67,7 @@ CONFIG_SCHEMA = ( ) -def auto_data_rate(config): +def auto_data_rate(config: ConfigType) -> MockObj: interval_msec = config[CONF_UPDATE_INTERVAL].total_milliseconds interval_hz = 1000.0 / interval_msec for datarate in sorted(MMC5603Datarates.keys()): @@ -74,7 +76,7 @@ def auto_data_rate(config): return MMC5603Datarates[75] -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) 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/mmc5983/sensor.py b/esphome/components/mmc5983/sensor.py index aaff2946f2..797181690f 100644 --- a/esphome/components/mmc5983/sensor.py +++ b/esphome/components/mmc5983/sensor.py @@ -10,6 +10,7 @@ from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_MICROTESLA, ) +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] @@ -39,7 +40,7 @@ CONFIG_SCHEMA = ( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index f6e0f98857..769858e72a 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,20 +1,51 @@ from __future__ import annotations -from typing import Literal +import logging +from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ADDRESS, CONF_DISABLE_CRC, CONF_FLOW_CONTROL_PIN, CONF_ID +from esphome.const import ( + CONF_ADDRESS, + CONF_CONTINUOUS, + CONF_DISABLE_CRC, + CONF_FLOW_CONTROL_PIN, + CONF_ID, +) +from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv +from esphome.types import ConfigType, TemplateArgsType + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["uart"] +# Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is +# generated unless a config uses one). +AUTO_LOAD = ["modbus_client"] + +# Mirrors modbus::MAX_PDU_SIZE in modbus_definitions.h: 256-byte RTU frame minus address and CRC. +MAX_PDU_SIZE = 253 + +# Mirror the per-function entity count limits from modbus_definitions.h. Keep these in step with the +# C++ constants of the same name; the spec sets a different ceiling for each function code. +MAX_NUM_OF_COILS_TO_READ = 2000 +MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 +MAX_NUM_OF_COILS_TO_WRITE = 1968 +MAX_NUM_OF_REGISTERS_TO_READ = 125 +MAX_NUM_OF_REGISTERS_TO_WRITE = 123 +MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121 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") +CommandOptions = modbus_ns.struct("CommandOptions") MULTI_CONF = True CONF_ROLE = "role" @@ -22,70 +53,217 @@ 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"] + + +class _CommandOption(NamedTuple): + """One per-command option forwarded to the hub (modbus::CommandOptions).""" + + conf_key: str + field: str # the C++ field, and so the set_() setter name + validator: Any # the static (non-templatable) validator for the key + cpp_type: Any # the C++ type the value is generated as + default: Any + + +# Per-direction command options. Single-sourcing the schema and the setter generation here keeps +# them from drifting; the C++ side must add the matching field per the rules documented on +# CommandOptions (modbus.h). +_COMMAND_OPTIONS: dict[str, list[_CommandOption]] = { + "read": [_CommandOption(CONF_CONTINUOUS, "continuous", cv.boolean, bool, False)], + "write": [], } -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, - } + +def _command_options(direction: str) -> list[_CommandOption]: + try: + return _COMMAND_OPTIONS[direction] + except KeyError: + raise ValueError(f"unknown command-options direction {direction!r}") from None + + +# The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 +# (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. +_WRITE_FUNCTION_CODES = frozenset({0x05, 0x06, 0x0F, 0x10, 0x16, 0x17}) + + +def is_function_code_write(function_code: int) -> bool: + """True if the Modbus function code writes (mutates). The exception bit (0x80) is masked off first, + so an exception-flagged code still classifies by its base code - stricter than the runtime hub, + whose classify() treats an exception-flagged code as a read. Keep in sync with + modbus::helpers::is_function_code_write().""" + return function_code & 0x7F in _WRITE_FUNCTION_CODES + + +def command_options_schema( + *, direction: Literal["read", "write"], templatable: bool = False +) -> dict[cv.Optional, Any]: + """Schema fragment for the per-command options a component forwards to the hub + (modbus::CommandOptions). Extend this into any schema that queues commands. Keys are + direction-specific so a schema never offers an option the hub would strip (e.g. + continuous on a write); the write side has no options yet. For actions (templatable=True the + keys also accept lambdas), register the values with register_templatable_command_options(). + """ + return { + cv.Optional(option.conf_key, default=option.default): ( + cv.templatable(option.validator) if templatable else option.validator + ) + for option in _command_options(direction) + } + + +def command_options_expression( + config: ConfigType, *, direction: Literal["read", "write"] +) -> cg.StructInitializer: + """Build the modbus::CommandOptions initializer for a config validated with + command_options_schema() of the same direction. For static (non-templatable) options only; + actions with lambda values use register_templatable_command_options() instead. + """ + return cg.StructInitializer( + CommandOptions, + *( + # Construct the value as its declared cpp_type, so a future non-bool option (enum, + # uint16_t, ...) is emitted with the right type instead of whatever safe_exp() infers. + (option.field, option.cpp_type(config[option.conf_key])) + for option in _command_options(direction) + if option.conf_key in config + ), ) - .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + + +async def register_templatable_command_options( + var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str +) -> None: + """Generate the set_