diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000000..92706fed20b --- /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/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 44722ec85c7..2244963a79d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,29 @@ body: If you have a feature request or enhancement, please [request them here instead][fr]. [fr]: https://github.com/orgs/esphome/discussions + - type: markdown + attributes: + value: | + ## Use of AI in bug reports + + AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting. + Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved + your problem, its wild guesses are not likely to help. + + Please DO include your own words and observations, compile/boot logs, and + especially a minimal reproducible example of your YAML configuration that demonstrates the problem. + + It is however quite acceptable to use AI to translate your *own* report, + if you aren't a competent English speaker. + + If you really think it will be useful to include an AI's analysis, preferably wrap it in a `
` block which will be collapsed by default. + + If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is, + it can be more useful to ask it to create a step-by-step troubleshooting procedure. + AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML + configuration that demonstrates the problem. + + Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way. - type: textarea validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 3b39d519c4a..977fe9428d3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -8,7 +8,7 @@ contact_links: 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/actions/cache-clang-tidy-idedata/action.yml b/.github/actions/cache-clang-tidy-idedata/action.yml new file mode 100644 index 00000000000..18f3c2b31ae --- /dev/null +++ b/.github/actions/cache-clang-tidy-idedata/action.yml @@ -0,0 +1,50 @@ +name: Cache clang-tidy idedata +description: > + Cache the clang-tidy idedata and the headers it references under .temp + (headers only, about 30MB per env). Run after restore-python and cache-esp-idf. +inputs: + environment: + description: 'clang-tidy environment (e.g. esp32-idf-tidy).' + required: true +runs: + using: composite + steps: + - name: Compute cache key + id: key + shell: bash + run: | + . venv/bin/activate + [ -n "${{ inputs.environment }}" ] || { echo "::error::cache-clang-tidy-idedata: 'environment' input is empty"; exit 1; } + hash=$(python -c 'import sys; sys.path.insert(0, "script"); from clang_tidy_hash import idedata_cache_hash; print(idedata_cache_hash("${{ inputs.environment }}"))') + pyver=$(python -c 'import platform; print(platform.python_version())') + # Generating idedata is what installs ESP-IDF; never skip it over a missing + # install. This also skips the save, so a dev run that installs ESP-IDF + # warms the idedata cache on the next run. + if [ -d ~/.esphome-idf/frameworks ]; then + echo "skip=false" >> "$GITHUB_OUTPUT" + else + echo "ESP-IDF install missing, not using the clang-tidy idedata cache" + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + echo "key=${{ runner.os }}-tidy-idedata-${{ inputs.environment }}-$hash-py$pyver" >> "$GITHUB_OUTPUT" + { + echo "path<> "$GITHUB_OUTPUT" + # Mirror cache-esp-idf: write on dev, restore-only on PRs. The post-step + # save only runs when the job succeeded, so a failed generation is never saved. + # Extend the extension list if a component ships extensionless headers. + - name: Cache clang-tidy idedata (write on dev) + if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && steps.key.outputs.skip != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.key.outputs.path }} + key: ${{ steps.key.outputs.key }} + - name: Cache clang-tidy idedata (restore-only off dev) + if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') && steps.key.outputs.skip != 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.key.outputs.path }} + key: ${{ steps.key.outputs.key }} diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index f566ba4c434..38bcc80eb67 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -3,8 +3,10 @@ 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", so all toolchains are present regardless of the chip). + 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: @@ -24,6 +26,9 @@ runs: # 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). + # Also key on the Python version: the cached IDF venv links to the + # runner's toolcache interpreter and is reinstalled every run after a + # runner image bump. id: version shell: bash run: | @@ -34,19 +39,32 @@ runs: version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])') fi echo "version=$version" >> "$GITHUB_OUTPUT" + echo "python-version=$(python -c 'import platform; print(platform.python_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). + # their own scope / the repo quota (e.g. on a version-bump PR). The + # ci-cache-write label lets a PR write into its own scope to test the hit path; + # that costs about 1GB of the repo cache quota per run, so remove it when done. + # -slim: bump when prune-esp-idf changes what it removes; a key is never overwritten. - name: Cache ESP-IDF install (write on dev) - if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' + if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && inputs.restore-only != 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.esphome-idf - key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }} + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim - name: Cache ESP-IDF install (restore-only off dev) - if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' + if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') || 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 }} + key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim + # Install explicitly so the prune below sees the toolchains on a cache miss + # too, instead of the install happening inside the first build step. + - name: Install ESP-IDF + shell: bash + run: | + . venv/bin/activate + python -c 'from esphome.espidf.framework import check_esp_idf_install; check_esp_idf_install("${{ steps.version.outputs.version }}")' + - name: Prune ESP-IDF install + uses: ./.github/actions/prune-esp-idf diff --git a/.github/actions/prune-esp-idf/action.yml b/.github/actions/prune-esp-idf/action.yml new file mode 100644 index 00000000000..e0e7c5bd4e7 --- /dev/null +++ b/.github/actions/prune-esp-idf/action.yml @@ -0,0 +1,34 @@ +name: Prune ESP-IDF install +description: > + Remove the picolibc sysroots (1.1GB of the 3.9GB install) from the native + ESP-IDF toolchains; IDF 5.x links newlib. Skipped when an IDF 6 install is + present, which links picolibc (see esp32/__init__.py). +runs: + using: composite + steps: + - name: Prune picolibc + shell: bash + run: | + shopt -s nullglob + prefix="${ESPHOME_ESP_IDF_PREFIX:-$HOME/.esphome-idf}" + prefix="${prefix/#\~/$HOME}" + for fw in "$prefix"/frameworks/*/; do + case "$(basename "$fw")" in + [6-9].*) echo "IDF $(basename "$fw") installed, keeping picolibc"; exit 0 ;; + esac + done + n=0 + for dir in "$prefix"/tools/*-esp-elf/*/*-esp-elf/picolibc; do + echo "Removing $dir ($(du -sh "$dir" | cut -f1))" + rm -rf "$dir" + n=$((n + 1)) + done + # The marker rides along in the cache entry so a restored slim tree stays quiet. + if [ "$n" -gt 0 ]; then + touch "$prefix/.picolibc-pruned" + elif [ -d "$prefix/tools" ] && [ ! -f "$prefix/.picolibc-pruned" ]; then + echo "::warning::no picolibc sysroots matched under $prefix/tools" + fi + if [ -d "$prefix" ]; then + du -sh "$prefix" + fi diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index dcd24958098..ce14b0152a8 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -49,7 +49,7 @@ runs: python -m venv venv source venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + 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' @@ -58,5 +58,5 @@ runs: python -m venv venv source ./venv/Scripts/activate python --version - uv pip install -r requirements.txt -r requirements_test.txt + uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt uv pip install -e . diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index b95bc17518a..5e8acb09c96 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', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index 2478ccf959d..1d76c18be81 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -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'); } diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index c8bdcfb2f38..8b0c8215031 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,7 +118,7 @@ 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), diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js index 413fdb3f945..be239e2f1bb 100644 --- a/.github/scripts/auto-label-pr/tests/detectors.test.js +++ b/.github/scripts/auto-label-pr/tests/detectors.test.js @@ -1,6 +1,7 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { + detectMergeBranch, detectNewPlatforms, detectNewComponents, detectPRSize, @@ -36,6 +37,122 @@ const API_DATA = { 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 // --------------------------------------------------------------------------- diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 30915b68c78..b49c66b976d 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -35,7 +35,7 @@ jobs: # 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 820081cc464..63219a1dbcd 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and @@ -41,10 +41,32 @@ jobs: 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: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 926e96078b8..829bdd5f981 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -67,15 +67,17 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Determine tag and whether to push id: tag + env: + HEAD_REF: ${{ github.head_ref || github.ref_name }} run: | # Sanitize the branch name into a valid docker tag: replace invalid # characters, ensure the first character is valid (tags must start # with [A-Za-z0-9_]), and cap the length at 128 characters. - branch="${{ github.head_ref || github.ref_name }}" + branch="$HEAD_REF" tag="${branch//[^a-zA-Z0-9_.-]/-}" case "$tag" in [a-zA-Z0-9_]*) ;; @@ -96,7 +98,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -117,16 +119,22 @@ jobs: # pushed image) keeps it working for fork PRs, which never push to ghcr.io. - name: Export image for compile-test if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz + # 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 gzipped, so upload it as-is. archive: false skips - # the redundant zip and makes the file name the artifact name (the - # `name` input is ignored in that mode). - path: compile-test-image.tar.gz + # 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 @@ -151,10 +159,10 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to the GitHub container registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -180,8 +188,6 @@ jobs: contents: read # actions/checkout to load the test configs strategy: fail-fast: false - # Cap concurrency so this smoke test doesn't hog all the shared runners. - max-parallel: 2 matrix: # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # share a toolchain bundle, so esp32 is exercised on the base variant @@ -206,9 +212,9 @@ jobs: - name: Download image artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: compile-test-image.tar.gz + name: compile-test-image.tar.zst - name: Load image - run: docker load --input compile-test-image.tar.gz + run: docker load --input compile-test-image.tar.zst - name: Compile ${{ matrix.id }} run: | docker run --rm \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72ece5b4fda..173d2c227ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -65,9 +65,153 @@ jobs: python -m venv venv . venv/bin/activate python --version - uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit + 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-run-all: ${{ steps.determine.outputs.integration-run-all }} + 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 + # A missing key must fail here, not silently disable the junit upload + run_all=$(echo "$output" | jq -r 'if has("integration_run_all") then .integration_run_all else error("integration_run_all missing") end') + echo "integration-run-all=${run_all}" >> $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 runs-on: ubuntu-24.04 @@ -91,117 +235,43 @@ jobs: run: script/ci-suggest-changes if: always() - ci-custom: - name: Run script/ci-custom - runs-on: ubuntu-24.04 + lint-format: + name: Check lint and formatting + runs-on: ubuntu-latest needs: - - common - determine-jobs - if: needs.determine-jobs.outputs.core-ci == 'true' + 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: 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 + - name: Read prek version from requirements_test.txt + id: prek + # requirements_test.txt is the only place the version is pinned, so a + # Dependabot bump there is picked up here without a second edit. 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-rp2-boards.py --check - script/ci_check_duplicate_test_ids.py - - import-time: - name: Check import esphome.__main__ time - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.import-time == 'true' - steps: - - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Restore Python - uses: ./.github/actions/restore-python + if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then + echo "::error::No prek== pin found in requirements_test.txt." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Run prek + uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} - cache-key: ${{ needs.common.outputs.cache-key }} - - name: Check import time against budget and write waterfall HAR - run: | - . venv/bin/activate - script/check_import_time.py --check --har importtime.har - - name: Upload waterfall HAR + prek-version: ${{ steps.prek.outputs.version }} + # 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() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: import-time-waterfall - path: importtime.har - if-no-files-found: ignore - retention-days: 14 - - device-builder: - name: Test downstream esphome/device-builder - runs-on: ubuntu-24.04 - needs: - - common - - determine-jobs - if: needs.determine-jobs.outputs.device-builder == 'true' - steps: - - name: Check out esphome (this PR) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - 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 + msg: apply automatic formatting fixes pytest: name: Run pytest @@ -252,8 +322,6 @@ jobs: 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 - with: - token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -261,98 +329,37 @@ jobs: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} - determine-jobs: - name: Determine which jobs to run + codecov-empty-upload: + name: Report no coverage to Codecov 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 }} + - 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: - # 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') }} + run_command: empty-upload + force: true + fail_ci_if_error: true integration-tests: name: Run integration tests (${{ matrix.bucket.name }}) - runs-on: ubuntu-latest + # 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 @@ -361,14 +368,42 @@ jobs: fail-fast: false matrix: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} + env: + # What the cache steps persist; libdeps is excluded (keyed per xdist + # worker and env, it never crosses runs). + INTEGRATION_PIO_CACHE_PATH: | + ~/.esphome-integration-tests/platformio/platforms + ~/.esphome-integration-tests/platformio/packages + ~/.esphome-integration-tests/platformio/appstate.json + ~/.esphome-integration-tests/platformio/.cache + ~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json steps: - name: Check out code from GitHub 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 needed by the headless display tests, which capture + # screenshots. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + 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 integration PlatformIO cache + # Native platform + toolchain installed by shared_platformio_cache in + # tests/integration/conftest.py; a miss self-heals, so no restore-keys. + id: pio-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} + key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -378,7 +413,7 @@ jobs: - 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -406,8 +441,173 @@ jobs: run: | . venv/bin/activate mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]') + if [ "${#test_files[@]}" -eq 0 ]; then + echo "::error::Empty integration test bucket; pytest would collect the whole tree" + exit 1 + fi echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests" - pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}" + pytest -vv --no-cov --tb=native --durations=30 -n auto --dist worksteal \ + --junitxml=junit-integration.xml "${test_files[@]}" + - name: Upload test artifacts + # Tests that compare rendered output write the image they actually got here, so a + # failure can be looked at without reproducing the whole build locally. + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-test-artifacts-${{ matrix.bucket.name }} + path: test_artifacts/ + if-no-files-found: ignore + retention-days: 7 + - name: Upload junit timings + # Consumed by sync-integration-durations.yml through + # script/update_integration_test_durations.py; only full matrix dev + # runs produce usable data. + if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: junit-integration-${{ strategy.job-index }} + path: junit-integration.xml + if-no-files-found: error + # A full cron period of margin for the weekly refresh + retention-days: 14 + - 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 + - name: Save integration PlatformIO cache + # Bucket 0 only; the others would race the same immutable key. + if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} + key: ${{ steps.pio-cache.outputs.cache-primary-key }} + + import-time: + name: Check import esphome.__main__ time + runs-on: ubuntu-24.04 + needs: + - common + - determine-jobs + if: needs.determine-jobs.outputs.import-time == '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: Check import time against budget and write waterfall HAR + run: | + . venv/bin/activate + script/check_import_time.py --check --har importtime.har + - name: Upload waterfall HAR + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: import-time-waterfall + path: importtime.har + if-no-files-found: ignore + retention-days: 14 + + benchmarks: + name: Run CodSpeed benchmarks + runs-on: ubuntu-24.04 + timeout-minutes: 30 + needs: + - common + - determine-jobs + 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@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: 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 + + - 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: | + 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 @@ -436,43 +636,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@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: 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@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5 - with: - run: | - . venv/bin/activate - ${{ steps.build.outputs.binary }} - pytest tests/benchmarks/python/ --codspeed --no-cov - mode: simulation - clang-tidy-single: name: ${{ matrix.name }} runs-on: ubuntu-24.04 @@ -488,7 +651,6 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: 2 matrix: include: - id: clang-tidy @@ -522,24 +684,29 @@ jobs: 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' && 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' && 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 @@ -547,6 +714,12 @@ jobs: with: framework: arduino + - name: Cache clang-tidy idedata + if: matrix.cache_idf + uses: ./.github/actions/cache-clang-tidy-idedata + with: + environment: esp32-arduino-tidy + - name: Cache nRF Connect SDK install if: matrix.cache_sdk_nrf uses: ./.github/actions/cache-sdk-nrf @@ -596,6 +769,10 @@ jobs: # Also cache libdeps, store them in a ~/.platformio subfolder PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps + - name: Prune ESP-IDF install before cache save + if: matrix.cache_idf && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) + uses: ./.github/actions/prune-esp-idf + - name: Suggested changes run: script/ci-suggest-changes ${{ matrix.ignore_errors && '|| true' || '' }} # yamllint disable-line rule:line-length @@ -628,6 +805,11 @@ jobs: - name: Cache ESP-IDF install uses: ./.github/actions/cache-esp-idf + - name: Cache clang-tidy idedata + uses: ./.github/actions/cache-clang-tidy-idedata + with: + environment: esp32-idf-tidy + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -662,6 +844,10 @@ jobs: # Also cache libdeps, store them in a ~/.platformio subfolder PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps + - name: Prune ESP-IDF install before cache save + if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) + uses: ./.github/actions/prune-esp-idf + - name: Suggested changes run: script/ci-suggest-changes if: always() @@ -679,7 +865,6 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 3 matrix: include: - id: clang-tidy @@ -708,6 +893,11 @@ jobs: - name: Cache ESP-IDF install uses: ./.github/actions/cache-esp-idf + - name: Cache clang-tidy idedata + uses: ./.github/actions/cache-clang-tidy-idedata + with: + environment: esp32-idf-tidy + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -742,6 +932,10 @@ jobs: # Also cache libdeps, store them in a ~/.platformio subfolder PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps + - name: Prune ESP-IDF install before cache save + if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) + uses: ./.github/actions/prune-esp-idf + - name: Suggested changes run: script/ci-suggest-changes if: always() @@ -759,23 +953,25 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf strategy: fail-fast: false - max-parallel: 3 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 + tidy_environment: esp32s3-idf-tidy - 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 + tidy_environment: esp32p4-idf-tidy - 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 + tidy_environment: esp32c6-idf-tidy steps: - name: Check out code from GitHub @@ -793,6 +989,11 @@ jobs: - name: Cache ESP-IDF install uses: ./.github/actions/cache-esp-idf + - name: Cache clang-tidy idedata + uses: ./.github/actions/cache-clang-tidy-idedata + with: + environment: ${{ matrix.tidy_environment }} + - name: Register problem matchers run: | echo "::add-matcher::.github/workflows/matchers/gcc.json" @@ -826,6 +1027,10 @@ jobs: script/clang-tidy --fix --changed ${{ matrix.options }} fi + - name: Prune ESP-IDF install before cache save + if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) + uses: ./.github/actions/prune-esp-idf + - name: Suggested changes run: script/ci-suggest-changes if: always() @@ -846,7 +1051,6 @@ jobs: 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: batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: @@ -858,12 +1062,17 @@ jobs: - name: List components run: echo ${{ matrix.batch.components }} - - name: Install apt packages - # Not cached: this job is pull-request-only, so a cache save could - # never be shared and would only consume quota. - run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends libsdl2-dev ccache + - name: 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 ccache + version: 1.1 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -928,7 +1137,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" @@ -1037,69 +1246,62 @@ jobs: # Arduino framework via PlatformIO (only components with an esp32-ard test are built): python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio - pre-commit-seed-cache: - name: Seed pre-commit cache - runs-on: ubuntu-latest - needs: - - common - # Saves a dev-scoped pre-commit cache that pull request runs can - # restore, since pre-commit.ci lite itself never runs on dev pushes. - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - steps: - - name: Check out code from GitHub - uses: actions/checkout@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 pre-commit environments - id: cache-pre-commit - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/pre-commit - # Must match the restore key in pre-commit-ci-lite - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Install pre-commit hook environments - if: steps.cache-pre-commit.outputs.cache-hit != 'true' - run: | - python -m pip install pre-commit - pre-commit install-hooks - - pre-commit-ci-lite: - name: pre-commit.ci lite - runs-on: ubuntu-latest + device-builder: + name: Test downstream esphome/device-builder + runs-on: ubuntu-24.04 needs: - common - determine-jobs - if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' + if: needs.determine-jobs.outputs.device-builder == 'true' steps: - - name: Check out code from GitHub + - name: Check out esphome (this PR) 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 }} - # Inlined from esphome/pre-commit-action with a restore-only cache - # step: the pre-commit-seed-cache job owns saving this cache, so - # pull request runs never write per-PR copies. - - name: Restore pre-commit cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + path: esphome + - name: Check out esphome/device-builder + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - path: ~/.cache/pre-commit - # Must match the key pre-commit-seed-cache saves - # yamllint disable-line rule:line-length - key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Run pre-commit - env: - SKIP: pylint,ci-custom + 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: | - python -m pip install pre-commit - pre-commit run --show-diff-on-failure --color=always --all-files - - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - if: always() + 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 @@ -1400,21 +1602,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 - clang-tidy-esp32-variants - - determine-jobs - - device-builder - test-build-components-split - test-esp32-platformio - - pre-commit-ci-lite + - device-builder - memory-impact-target-branch - memory-impact-pr-branch - memory-impact-comment diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 953f19c0a9d..aab3dea592c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 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@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index ec736a20029..e09e9bf2d17 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@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1 diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 00000000000..cd3b7207b72 --- /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 b8dbb7414d3..d0dee8165c5 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 @@ -23,6 +34,8 @@ jobs: - 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 @@ -70,7 +94,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: skip-existing: true @@ -99,15 +123,15 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -178,17 +202,17 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ef79b2705a6..38d2418ac62 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@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.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 merge the latest dev branch into your + 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/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml new file mode 100644 index 00000000000..5599691ed19 --- /dev/null +++ b/.github/workflows/sync-dependency-versions.yml @@ -0,0 +1,94 @@ +# Keeps pre-commit hook revs in sync with the requirements files. +# +# Dependabot only bumps the pins in requirements*.txt. Some of those tools +# are pinned again as hook revs in .pre-commit-config.yaml. This workflow +# runs script/sync_dependency_versions.py against the pull request branch +# and pushes a commit with the revs updated. + +name: Sync dependency versions + +on: + # pull_request_target rather than pull_request so the App secret is + # available on Dependabot pull requests (pull_request runs opened by + # Dependabot only see Dependabot secrets). The job below only touches + # branches in this repository and only ever executes the script from the + # base branch checkout, so fork code never runs with the token. + pull_request_target: + types: [opened, synchronize, reopened] + paths: + - requirements_dev.txt + - requirements_test.txt + - .pre-commit-config.yaml + - script/sync_dependency_versions.py + +# The push to the pull request branch uses the App token minted below, so +# the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync pinned versions + runs-on: ubuntu-latest + # Same-repository branches only: a push to a fork is not possible with + # this token, and it keeps untrusted heads out of a privileged job. + if: >- + github.repository == 'esphome/esphome' + && github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # A push made with the workflow's own GITHUB_TOKEN would not start + # CI on the new commit; a push with the App token does. + permission-contents: write # git push of the sync commit to the pull request branch + + - name: Check out base branch + # Provides the script that runs below. Deliberately the base branch + # so the pull request cannot change what executes here. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Check out pull request branch + # No allow-unsafe-pr-checkout here on purpose: checkout v7 only + # refuses heads that live in a different repository, and the job + # condition above already limits runs to same-repository branches. + # Leaving it off keeps that refusal as a backstop for fork heads. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + path: pull-request + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install yamlrocks + # The script edits YAML through yamlrocks. Take the pin from the + # base branch requirements so this workflow has no copy of its own. + run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)" + + - name: Sync pinned versions + run: python script/sync_dependency_versions.py --root pull-request + + - name: Push changes + working-directory: pull-request + run: | + if git diff --quiet; then + echo "All pinned versions already match the requirements files." + exit 0 + fi + git config user.name "esphome[bot]" + git config user.email "115708604+esphome[bot]@users.noreply.github.com" + git commit -am "Sync pinned tool versions with requirements files" + git push diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 5bec463a33d..9100064176c 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -44,10 +44,10 @@ jobs: - 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 ``pre-commit`` / + # 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + 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 @@ -58,19 +58,19 @@ jobs: - name: Install Home Assistant run: | uv pip install --system -e lib/home-assistant - uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit + uv pip install --system -r requirements.txt -r requirements_test.txt - name: Sync run: | python ./script/sync-device_class.py - - name: Apply pre-commit auto-fixes + - name: Apply prek auto-fixes # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify - # files. pre-commit exits non-zero whenever a hook touches anything, + # 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. # - # SKIP: + # 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. @@ -79,25 +79,25 @@ jobs: # the runtime deps (HA + requirements*.txt); main CI already # gates pylint on real PRs. env: - SKIP: pylint,no-commit-to-branch - run: python script/run-in-env.py pre-commit run --all-files || true + PREK_SKIP: pylint,no-commit-to-branch + run: python script/run-in-env.py prek run --all-files || true - - name: Verify pre-commit clean + - 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 SKIP list as + # real issue and fails the workflow loudly. Same PREK_SKIP list as # above for the same reasons. env: - SKIP: pylint,no-commit-to-branch - run: python script/run-in-env.py pre-commit run --all-files + 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/.github/workflows/sync-integration-durations.yml b/.github/workflows/sync-integration-durations.yml new file mode 100644 index 00000000000..d09a1cf2428 --- /dev/null +++ b/.github/workflows/sync-integration-durations.yml @@ -0,0 +1,98 @@ +--- +name: Refresh integration test durations + +on: + workflow_dispatch: + schedule: + - cron: "45 5 * * 1" + +# Repo writes (branch push, PR open) happen via the App token minted below, +# so the workflow's GITHUB_TOKEN does not need any write scopes. +permissions: + contents: read + actions: read # gh api / gh run download for the CI junit artifacts + +jobs: + sync: + name: Refresh integration test durations + runs-on: ubuntu-latest + if: github.repository == 'esphome/esphome' + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + permission-contents: write # push the sync branch + permission-pull-requests: write # open or refresh the sync PR + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Refresh from the newest usable dev run + env: + GH_TOKEN: ${{ github.token }} + run: | + # Only full matrix dev runs upload junit-integration-* artifacts + # (see the integration-tests job); the merge script re-checks + # coverage regardless. + # Newest-first candidates via their bucket-0 artifact. Fork PRs run + # their own ci.yml, so name and branch are spoofable; require + # same-repo. Assignment failures trip set -e and fail loudly. + candidates=$( + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=junit-integration-0&per_page=100" \ + --jq '.artifacts[] | select(.expired | not) | .workflow_run + | select(.head_branch == "dev" and .head_repository_id != null + and .head_repository_id == .repository_id) + | .id' + ) + # Green runs first, then the rest newest first; a run missing a + # bucket fails the coverage check and the next one is tried + green="" + rest="" + for id in ${candidates}; do + conclusion=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${id}" --jq '.conclusion // ""') + if [ "${conclusion}" = "success" ]; then + green="${green} ${id}" + elif [ -n "${conclusion}" ]; then + rest="${rest} ${id}" + fi + done + # helpers.py imports colorama; the script needs nothing else + pip install colorama + for id in ${green} ${rest}; do + rm -rf /tmp/junit + if ! gh run download "${id}" --repo "${GITHUB_REPOSITORY}" -p "junit-integration-*" -D /tmp/junit; then + echo "::warning::Could not download artifacts for run ${id}; trying the next" + continue + fi + status=0 + python script/update_integration_test_durations.py /tmp/junit || status=$? + if [ "${status}" -eq 0 ]; then + echo "Refreshed from run ${id}" + exit 0 + fi + # Only EXIT_LOW_COVERAGE (3) from the script advances to the next run + [ "${status}" -eq 3 ] || exit 1 + echo "::warning::Run ${id} covers too few test files; trying the next" + done + echo "::error::No dev CI run with usable junit artifacts in range; the feed is starved" + exit 1 + + - name: Commit changes + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + commit-message: "[ci] Refresh integration test durations" + committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> + author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> + branch: sync/integration-durations + delete-branch: true + title: "[ci] Refresh integration test durations" + body-path: .github/PULL_REQUEST_TEMPLATE.md + token: ${{ steps.generate-token.outputs.token }} diff --git a/.gitignore b/.gitignore index de3e4fa68e9..82b00286c71 100644 --- a/.gitignore +++ b/.gitignore @@ -133,8 +133,12 @@ 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/ +# Output kept by failing tests for inspection; uploaded by CI +test_artifacts/ /.temp-clang-tidy.cpp /.temp/ .pio/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da424f516f1..95e6f0f73e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks - ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit @@ -11,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.15 + rev: v0.16.6 hooks: # Run the linter. - id: ruff @@ -42,7 +41,7 @@ repos: - id: pyupgrade args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint exclude: ^(\.clang-format|\.clang-tidy)$ diff --git a/AGENTS.md b/AGENTS.md index 75a9cdb2bfd..8db3cd3d624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,16 @@ This document provides essential context for AI models interacting with this pro ## 4. Coding Conventions & Style Guide +**Read the developer documentation before writing a component.** https://developers.esphome.io covers the +component lifecycle, the main loop, and the reasoning behind the rules below in far more depth than this +file does, and it is the authority when they disagree. The most useful starting points: + +* https://developers.esphome.io/architecture/components/ - component lifecycle, `setup()`, `loop()`, + setup priorities, and how a component is registered. +* https://developers.esphome.io/architecture/components/advanced/ - choosing between `loop()`, + `set_interval`, `set_timeout` and `defer`; waking the loop from another thread; the RAM cost of each. +* https://developers.esphome.io/contributing/code/ - contribution rules, public API and breaking changes. + * **Formatting:** * **Python:** Uses `ruff` and `flake8` for linting and formatting. Configuration is in `pyproject.toml`. * **C++:** Uses `clang-format` for formatting. Configuration is in `.clang-format`. @@ -57,6 +67,12 @@ 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: @@ -136,6 +152,47 @@ This document provides essential context for AI models interacting with this pro * **Indentation:** Use spaces (two per indentation level), not tabs * **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;` * **Line length:** Wrap lines at no more than 120 characters + * **Timing in `loop()`:** Never call `millis()` in a `loop()` body. The current tick's timestamp is + already cached - use `App.get_loop_component_start_time()` (from `esphome/core/application.h`). + Only reach for `millis()` when you genuinely need sub-tick resolution inside a long operation. + * **The main loop runs every 16 ms.** A rate-limit gate shorter than that does nothing: the check + passes on essentially every pass of the loop, so it costs a comparison and buys nothing. Pick an + interval comfortably coarser than 16 ms, or drop the gate entirely and accept running every loop. + ```cpp + // Bad - a 10ms gate against a 16ms loop never holds anything back + static constexpr uint32_t POLL_INTERVAL_MS = 10; + const uint32_t now = millis(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + ``` + ```cpp + // Good - an interval that actually rate limits, off the cached timestamp + static constexpr uint32_t POLL_INTERVAL_MS = 100; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + ``` + Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use + `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: + https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Don't override a default with the same value:** if a base class method already returns what you + want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a + component that wants `DATA` should simply leave it alone. + ```cpp + // Bad - this is exactly what the base class already does + float get_setup_priority() const override { return setup_priority::DATA; } + ``` + * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they + can be stored in flash rather than RAM. + ```cpp + // Bad + ESP_LOGV(TAG, "Key %u %s", key, pressed ? "pressed" : "released"); + + // Good + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + ``` * **Constructor parameters vs setters:** Component properties that are both **required** and **invariant** (never change after construction) should be constructor parameters rather than set via setter methods. This makes the dependency explicit and prevents use of the object in an incompletely-initialized state. @@ -191,11 +248,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]) @@ -229,7 +289,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) @@ -238,7 +303,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) ``` @@ -246,7 +314,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) ``` @@ -263,10 +334,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]) @@ -316,11 +390,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, []): @@ -368,7 +445,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`. @@ -381,6 +461,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 @@ -388,7 +469,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. @@ -396,7 +477,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. @@ -469,9 +550,10 @@ 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. + 7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome.io` repository. @@ -532,6 +614,36 @@ This document provides essential context for AI models interacting with this pro Use `cg.add_define("MAX_SERVICES", count)` to set the size from Python configuration. Like `std::array` but with vector-like API (`push_back()`, `size()`) and no STL reallocation code. + **Listener and child-entity registration lists are the most common case, and the most commonly + missed.** A `register_*()` method called once per child at code generation time has a count that + is known at compile time, so it should never be a `std::vector`. Use `cg.slot_counter()`: it + returns a function that each consumer calls once per slot it will occupy, and after every + `to_code` has run it emits the define with the final count. When nothing registers, no define is + emitted and the storage plus its registration method compile out entirely. + ```python + # hub component's __init__.py + _request_listener_slot = cg.slot_counter("MY_COMPONENT_LISTENER_COUNT") + + + async def register_listener(hub: MockObj, var: MockObj) -> None: + _request_listener_slot() + cg.add(hub.register_listener(var)) + ``` + When several instances each own a list declared at the same size (one per hub of a + `MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`; + the define is then the largest count any one key requested instead of the total. + ```cpp + #ifdef MY_COMPONENT_LISTENER_COUNT + void register_listener(MyComponentListener *listener); + #endif + protected: + #ifdef MY_COMPONENT_LISTENER_COUNT + StaticVector listeners_; + #endif + ``` + Request slots from `to_code`, not from a job that runs after `CoroPriority.FINAL` - a late + request raises rather than silently undercounting. + 3. **Runtime-known sizes:** Use `FixedVector` from `esphome/core/helpers.h` when the size is only known at runtime initialization. ```cpp // Bad - generates STL realloc code (_M_realloc_insert) @@ -569,9 +681,25 @@ This document provides essential context for AI models interacting with this pro ``` Linear search on small datasets (1-16 elements) is often faster than hashing/tree overhead, but this depends on lookup frequency and access patterns. For frequent lookups in hot code paths, the O(1) vs O(n) complexity difference may still matter even for small datasets. `std::vector` with simple structs is usually fine—it's the heavy containers (`map`, `set`, `unordered_map`) that should be avoided for small datasets unless profiling shows otherwise. - 5. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. + 5. **Strings set once from configuration:** Use `StringRef` (`esphome/core/string_ref.h`) rather than + `std::string`. Code generation passes a string literal that lives in flash for the life of the + program, so storing a `std::string` copies it onto the heap for nothing. `StringRef` is a + non-owning pointer plus length; it does not copy, and it must only ever refer to storage that + outlives it (a string literal, or a buffer owned elsewhere). + ```cpp + // Bad - heap copy of a literal that is already in flash + void set_keys(std::string keys) { this->keys_ = std::move(keys); } + std::string keys_; + ``` + ```cpp + // Good - no allocation + void set_keys(const char *keys) { this->keys_ = StringRef(keys); } + StringRef keys_; + ``` - 6. **Detection:** Look for these patterns in compiler output: + 6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. + + 7. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) @@ -617,6 +745,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 @@ -636,20 +765,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) ``` @@ -704,10 +837,22 @@ 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 @@ -716,3 +861,13 @@ The project uses English for non-code content. When drafting documentation, code 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 5c2bf31165b..06d4d660349 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -70,6 +70,7 @@ 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- @@ -77,6 +78,7 @@ 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 @@ -98,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow @@ -109,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke +esphome/components/cdc_acm_uart/* @kbx81 +esphome/components/cdc_acm_uart/bridge/* @kbx81 esphome/components/ch422g/* @clydebarrow @jesterret esphome/components/ch423/* @dwmw2 esphome/components/chsc6x/* @kkosik20 @@ -129,6 +134,7 @@ esphome/components/cst816/* @clydebarrow esphome/components/cst9220/* @clydebarrow esphome/components/ct_clamp/* @jesserockz esphome/components/current_based/* @djwmarcx +esphome/components/d01/* @ch604 esphome/components/dac7678/* @NickB1 esphome/components/daikin_arc/* @MagicBear esphome/components/daikin_brc/* @hagak @@ -146,6 +152,7 @@ esphome/components/display_menu_base/* @numo68 esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee +esphome/components/ds1603l/* @JakeLC15 esphome/components/ds2484/* @mrk-its esphome/components/ds248x/* @tomwellnitz esphome/components/dsmr/* @glmnet @PolarGoose @@ -236,6 +243,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 @@ -287,6 +295,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 @@ -294,6 +303,8 @@ 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 @@ -344,10 +355,12 @@ esphome/components/mipi_spi/* @clydebarrow esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt +esphome/components/mk2pvrouter/* @FredM67 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 @@ -374,6 +387,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 @@ -436,6 +450,7 @@ 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 @@ -458,6 +473,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras 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 @@ -466,6 +482,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 @@ -482,6 +499,7 @@ esphome/components/sm2335/* @Cossid esphome/components/sml/* @alengwenus esphome/components/smt100/* @piechade esphome/components/sn74hc165/* @jesserockz +esphome/components/snapshot/* @clydebarrow esphome/components/socket/* @esphome/core esphome/components/sonoff_d1/* @anatoly-savchenkov esphome/components/sound_level/* @kahrendt @@ -567,6 +585,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 @@ -627,6 +646,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 3bb08e5b06e..16193713239 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.8.0-dev +PROJECT_NUMBER = 2026.10.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 5816f38176d..11656ff0b72 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -23,7 +23,8 @@ 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. + API/OTA encryption key, API 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 @@ -76,8 +77,8 @@ These *are* security bugs in this repo, and we want to hear about them privately 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. +- Flaws that weaken the device's API or OTA encryption (Noise), OTA auth, or + web server auth below their documented guarantees. ## The web server is an open HTTP API by design @@ -121,6 +122,54 @@ and any memory-safety or protocol bug in the server reachable without credential This section documents the current design and scope; it is not a judgment that the design is optimal or that it will not change. +## OTA update encryption + +The `esphome` OTA platform optionally encrypts updates with the same Noise +`NNpsk0` pattern the native API uses; one key protects the device. A device +whose `api:` block has an encryption key, static in the YAML or provisioned at +runtime, compiles in the transport and offers it on every OTA connection once +it holds a key, so an uploader presenting that key gets the guarantees below +even without an `ota: encryption:` block; only that block makes the device +require encryption. The guarantees are: the firmware image is confidential in +transit, the uploader is authenticated by the pre-shared key, and the plaintext +negotiation preceding the handshake is bound into the handshake prologue, so +stripping or tampering with it fails the first MAC. With `ota: encryption:` +configured both ends fail closed with no override: the device refuses +plaintext uploads, and the CLI refuses to send plaintext when a key is +configured. Without that block the CLI tries a static api key when the device +offers and, until 2027.3.0, falls back to plaintext with a warning when the +offer is missing or the handshake fails; a runtime provisioned key never +reaches the CLI, so those uploads stay plaintext. + +Defeating any of that without the key is in scope: a device that requires +encryption accepting a plaintext or downgraded upload, getting past the MAC, +or recovering image contents from captured traffic. + +The following are **not** vulnerabilities, by design: + +- Plaintext OTA on a device with no `ota: encryption:` block, including one + that offers encryption because it has an api key. That is the documented + default, authenticated (if at all) by the OTA password. An uploader that + takes the offer skips the password; the key authenticates it. With a + runtime provisioned key and no `provisioning:` window, whoever provisions + the key gains that upload path too; validation warns about the pair. +- The CLI plaintext fallback until 2027.3.0: without `ota: encryption:` an + active attacker who strips the offer or breaks the handshake can make a + keyed CLI upload plaintext, with the pre-existing plaintext exposure. A + device that requires encryption still refuses that upload. +- The enablement window: firmware built with a static api key already offers + encryption, so turning on `ota: encryption:` is itself an encrypted upload. + Older firmware needs one last plaintext upload of an offering build, with + the pre-existing plaintext exposure. +- The web OTA `/update` endpoint alongside encryption. With the `web_server` + or `prometheus` component the shared listener is always up, so the endpoint + stays reachable and validation warns about that combination; + `captive_portal:` alone brings the listener up only for the fallback AP + window, which is the intended recovery path, so that is not warned about. +- CLI retry behavior on transport or MAC failures; every attempt renegotiates + a fresh handshake with fresh ephemerals, so retrying does not weaken + authentication. + ## Explicitly out of scope - Local attackers who already have shell access on the host that runs `esphome`. diff --git a/docker/Dockerfile b/docker/Dockerfile index 638bbdbf9e9..e00570c8ff2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.6.10 +RUN uv pip install --no-cache-dir esphome-device-builder==1.14.9 RUN \ platformio settings set enable_telemetry No \ diff --git a/docker/generate_tags.py b/docker/generate_tags.py index 31f98c46140..b35aed91f02 100755 --- a/docker/generate_tags.py +++ b/docker/generate_tags.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import os import re CHANNEL_DEV = "dev" @@ -64,7 +65,10 @@ def main(): suffix = f"-{args.suffix}" if args.suffix else "" - image_name = f"esphome/esphome{suffix}" + repository = ( + (os.environ.get("GITHUB_REPOSITORY") or "esphome/esphome").strip().lower() + ) + image_name = f"{repository}{suffix}" print(f"channel={channel}") diff --git a/esphome/__main__.py b/esphome/__main__.py index e56b504398c..30e97f55eb4 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2,35 +2,35 @@ 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 +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 +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_ENCRYPTION, CONF_ESPHOME, + CONF_KEY, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -44,10 +44,12 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, + KEY_ESP32, + KEY_VARIANT, SECRETS_FILES, Toolchain, ) @@ -55,6 +57,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 ( @@ -70,6 +73,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 @@ -272,8 +278,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "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 ( @@ -313,9 +319,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)) @@ -327,7 +336,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: @@ -389,7 +402,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(): @@ -482,10 +495,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 @@ -546,11 +572,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( @@ -583,41 +646,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 @@ -630,18 +699,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 = 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 @@ -681,11 +741,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 @@ -708,9 +764,11 @@ def _wrap_to_code(name, comp, yaml_util): 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) @@ -801,7 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: toolchain.create_factory_bin() toolchain.create_ota_bin() toolchain.create_elf_copy() - toolchain.get_idedata() + 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 @@ -930,9 +1001,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 ( @@ -986,6 +1058,8 @@ 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 @@ -1023,6 +1097,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) @@ -1129,6 +1205,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 " @@ -1141,12 +1219,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 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) @@ -1258,9 +1335,28 @@ def _upload_via_native_api( break from esphome import espota2 + from esphome.components.noise import static_encryption_key remote_port = int(ota_conf[CONF_PORT]) password = ota_conf.get(CONF_PASSWORD) + # Fail closed: an encryption block whose key did not resolve must never + # fall back to a plaintext upload + noise_psk = None + plaintext_fallback = False + if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: + noise_psk = encryption_conf.get(CONF_KEY) + if not noise_psk: + raise EsphomeError( + "OTA encryption is configured but no key was resolved; " + "set the key under 'ota: encryption:' or 'api: encryption:'" + ) + # Ensure the key is a string, as required by the underlying OTA implementation. + # It arrives here as a SensitiveStr which aioesphomeapi rejects. + noise_psk = str(noise_psk) + elif api_key := static_encryption_key(config.get(CONF_API) or {}): + # Remove before 2027.3.0: the api key is tried, falling back to plaintext + noise_psk = str(api_key) + plaintext_fallback = True def check_partition_access(option_string: str) -> None: if not ota_conf.get("allow_partition_access"): @@ -1291,31 +1387,47 @@ def _upload_via_native_api( if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER: _validate_bootloader_binary(binary) - return espota2.run_ota(network_devices, remote_port, password, binary, ota_type) + return espota2.run_ota( + network_devices, + remote_port, + password, + binary, + ota_type, + noise_psk, + plaintext_fallback=plaintext_fallback, + ) 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 + if any( + ota_item.get(CONF_PLATFORM) == CONF_ESPHOME + and ota_item.get(CONF_ENCRYPTION) is not None + for ota_item in config.get(CONF_OTA, []) + ): + _LOGGER.warning( + "This config has OTA encryption, but the web_server OTA path sends " + "the image over plaintext HTTP; use the esphome OTA platform to " + "keep it confidential" + ) + 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 @@ -1406,12 +1518,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: - try: - module = importlib.import_module("esphome.components." + CORE.target_platform) - if 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!") @@ -1425,17 +1536,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=_should_subscribe_states(args), - ) + 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 @@ -1444,6 +1575,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)") @@ -1571,20 +1709,26 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None: if exit_code != 0: return exit_code if CORE.is_host: - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - program_path = str(toolchain.get_elf_path()) - else: - from esphome.platformio.toolchain import get_idedata - - program_path = str(get_idedata(config).firmware_elf_path) - _LOGGER.info("Successfully compiled program to path '%s'", program_path) + _LOGGER.info( + "Successfully compiled program to path '%s'", _host_program_path(config) + ) else: _LOGGER.info("Successfully compiled program.") return 0 +def _host_program_path(config: ConfigType) -> str: + """Return the compiled host ELF path.""" + if CORE.using_toolchain_esp_idf: + from esphome.espidf import toolchain + + return str(toolchain.get_elf_path()) + from esphome.platformio.toolchain import get_idedata + + # Memoized by compile_program's own call; this is a dict lookup + return str(get_idedata(config).firmware_elf_path) + + def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( @@ -1629,14 +1773,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: return exit_code _LOGGER.info("Successfully compiled program.") if CORE.is_host: - if CORE.using_toolchain_esp_idf: - from esphome.espidf import toolchain - - program_path = str(toolchain.get_elf_path()) - else: - from esphome.platformio.toolchain import get_idedata - - program_path = str(get_idedata(config).firmware_elf_path) + program_path = _host_program_path(config) _LOGGER.info("Running program from path '%s'", program_path) return run_external_process(program_path) @@ -1713,7 +1850,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) @@ -1948,7 +2085,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: " @@ -1961,7 +2098,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." ) @@ -2008,7 +2145,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( @@ -2026,7 +2165,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.", @@ -2036,7 +2175,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.", @@ -2044,7 +2183,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. " @@ -2052,7 +2191,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() @@ -2061,7 +2200,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 @@ -2087,7 +2226,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 @@ -2514,6 +2653,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 @@ -2532,6 +2714,7 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: @@ -2563,10 +2746,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 @@ -2588,10 +2772,14 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - cache_eligible = ( + cache_write_eligible = ( args.command in ("upload", "logs") and not command_line_substitutions ) - if cache_eligible: + # 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) @@ -2601,33 +2789,32 @@ def run_esphome(argv): conf_path.name, ) - if config is None: + 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), ) - # Refresh the cache so the next upload/logs hits the fast path - # instead of re-running read_config. Skip when the storage - # sidecar is absent (no compile has run): the cache would - # never be loaded back, so writing secrets to disk is wasted. - if cache_eligible and config is not None: - from esphome.compiled_config import save_compiled_config - from esphome.storage_json import ext_storage_path - - if ext_storage_path(conf_path.name).exists(): - save_compiled_config(config) - if config is None: - return 2 + 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/api_client.py b/esphome/api_client.py new file mode 100644 index 00000000000..2b93b4790f7 --- /dev/null +++ b/esphome/api_client.py @@ -0,0 +1,196 @@ +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/tests/unit_tests/components/api/__init__.py b/esphome/arduino/__init__.py similarity index 100% rename from tests/unit_tests/components/api/__init__.py rename to esphome/arduino/__init__.py diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py new file mode 100644 index 00000000000..e224e625897 --- /dev/null +++ b/esphome/arduino/library.py @@ -0,0 +1,531 @@ +"""Arduino-core backend for the shared PlatformIO library converter. + +Bundled names build straight from the framework tree; everything else goes +through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each +library builds its own archive; all include dirs join one global path. + +Deviations from PlatformIO: flat-layout libraries get the recursive default +source filter; ``dot_a_linkage`` is honored; bundled libraries never run a +manifest ``extraScript``; manifest ``-I`` flags join the global include path; +``precompiled``/``ldflags`` properties are refused by name. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +from pathlib import Path +import re + +from esphome.core import CORE, EsphomeError, Library +from esphome.helpers import walk_files +from esphome.platformio.extra_script import apply_extra_script +from esphome.platformio.library import ( + DEFAULT_BUILD_INCLUDE_DIR, + DEFAULT_BUILD_SRC_FILTER, + ESPHOME_DATA_KEY, + ESPHOME_DATA_LINK_FLAGS_KEY, + LIBRARY_HEADER_SUFFIXES, + SRC_FILE_EXTENSIONS, + ConvertedLibrary, + IncompatiblePlatform, + InvalidLibrary, + LibraryBackend, + _url_or_none, + check_library_data, + collect_filtered_files, + convert_libraries, + ensure_list, + is_lib_ignored, + lex_build_flags, + lib_ignore_set, + normalize_dependencies, + parse_library_json, + parse_library_properties, + warn_properties_depends, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class ArduinoLibrary: + """One resolved library, ready for the ninja generator.""" + + name: str + sources: list[Path] = field(default_factory=list) + include_dirs: list[Path] = field(default_factory=list) + # Extra compile flags private to this library's own sources + flags: list[str] = field(default_factory=list) + # PlatformIO's build.libArchive / Arduino's dot_a_linkage: when False the + # objects go to the linker directly (symbols nothing references survive) + lib_archive: bool = True + # Link inputs the library contributes (-L dirs / -l libs, e.g. from + # precompiled vendor blobs) and -Wl, options for the firmware link + link_dirs: list[Path] = field(default_factory=list) + link_libs: list[str] = field(default_factory=list) + link_flags: list[str] = field(default_factory=list) + + +# Source-like suffixes the case-sensitive suffix map rejects +_UNMAPPED_SOURCE_SUFFIXES = frozenset( + {s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"} +) + +# Filename-plain names: an allowlist excludes separators, drive colons, +# and dot-only names by shape +_SAFE_LIBRARY_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_. +-]*\Z") + + +def _is_safe_library_name(name: object) -> bool: + """Whether a name may be joined under the framework's libraries dir.""" + return isinstance(name, str) and _SAFE_LIBRARY_NAME_RE.fullmatch(name) is not None + + +def _manifest_build(name: str, data: object) -> dict: + """The manifest's ``build`` section; malformed manifests fail by name.""" + build = data.get("build", {}) if isinstance(data, dict) else None + if not isinstance(build, dict): + raise EsphomeError(f"Library {name} has a malformed manifest") + return build + + +def _resolve_src_dir(name: str, read_path: Path, build: dict) -> str: + """Resolve PIO's source dir: manifest srcDir, else src/Src, else the root.""" + if "srcDir" not in build: + return next((d for d in ("src", "Src") if (read_path / d).is_dir()), ".") + # A declared srcDir (falsy included) that does not resolve is a manifest error + src_dir = build["srcDir"] + if not (isinstance(src_dir, str) and src_dir and (read_path / src_dir).is_dir()): + raise EsphomeError( + f"Library {name} declares srcDir {src_dir!r} which does not exist" + ) + return src_dir + + +def _reject_unsupported_link_fields(name: str, data: dict) -> None: + # PIO honors these; ignoring them would fail at link with no stated + # cause. Property values are strings, so "false" is not a declaration. + precompiled = data.get("precompiled") + if precompiled and str(precompiled).strip().lower() != "false": + raise EsphomeError( + f"Library {name} declares precompiled, which this backend does not support" + ) + if data.get("ldflags"): + raise EsphomeError( + f"Library {name} declares ldflags, which this backend does not support" + ) + + +def _resolve_lib_archive(name: str, data: dict, build: dict) -> bool: + """build.libArchive, else dot_a_linkage (an Arduino IDE property PIO + ignores; a deliberate extra), else archive.""" + + # Strict parse: bool("false") is True + def _parse(key: str, raw: object) -> bool: + if isinstance(raw, bool): + return raw + value = str(raw).strip().lower() + if value in ("true", "false"): + return value == "true" + raise EsphomeError(f"Library {name} has a malformed {key} value {raw!r}") + + if "libArchive" in build: + return _parse("libArchive", build["libArchive"]) + if "dot_a_linkage" in data: + return _parse("dot_a_linkage", data["dot_a_linkage"]) + return True + + +def _classify_build_flags( + name: str, read_path: Path, lib: ArduinoLibrary, flag_tokens: list[str] +) -> list[str]: + """Route the lexed build.flags into the library's flag lists. + + Returns the ``-I`` arguments for the include-dir resolution. + """ + include_flags: list[str] = [] + for tok in flag_tokens: + if tok.startswith("-I"): + include_flags.append(tok[2:]) + elif tok.startswith("-L"): + link_dir = (read_path / tok[2:]).resolve() + if not link_dir.is_dir(): + # Kept (the linker ignores missing -L dirs); the warning + # names the culprit before a bare "cannot find -lfoo" + _LOGGER.warning( + "Library %s declares library dir %s which does not exist", + name, + tok[2:], + ) + lib.link_dirs.append(link_dir) + elif tok.startswith("-l"): + lib.link_libs.append(tok[2:]) + elif tok.startswith("-Wl,"): + lib.link_flags.append(tok) + else: + lib.flags.append(tok) + return include_flags + + +def _resolve_include_dirs( + name: str, + read_path: Path, + lib: ArduinoLibrary, + build: dict, + src_dir: str, + include_flags: list[str], +) -> None: + include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) + if not isinstance(include_dir, str): + raise EsphomeError(f"Library {name} has a malformed includeDir") + for d, explicit in [ + (include_dir, "includeDir" in build), + (src_dir, False), # _resolve_src_dir already validated it + *((flag, True) for flag in include_flags), + ]: + if (path := (read_path / d)).is_dir(): + lib.include_dirs.append(path.resolve()) + elif explicit: + # Warn-and-drop (unlike srcDir): a missing include dir is + # harmless until a header is needed, and the compile names it + _LOGGER.warning( + "Library %s declares include dir %s which does not exist", name, d + ) + + +def _collect_lib_sources( + name: str, + read_path: Path, + lib: ArduinoLibrary, + src_dir: str, + src_filter: list[str], +) -> None: + sources: list[Path] = [] + dropped: list[str] = [] + saw_header = False + for f in collect_filtered_files(read_path / src_dir, src_filter): + path = Path(f) + suffix = path.suffix + if suffix in SRC_FILE_EXTENSIONS: + # resolve() per file: srcFilter patterns may escape src_dir + sources.append(path.resolve()) + elif suffix.lower() in _UNMAPPED_SOURCE_SUFFIXES: + # A source-like suffix the case-sensitive map rejects (.CPP, + # .ino) is a dropped compilation unit; headers fall through + dropped.append(path.name) + elif suffix.lower() in LIBRARY_HEADER_SUFFIXES: + saw_header = True + lib.sources = sorted(sources) + if dropped: + _LOGGER.warning( + "Library %s: %d file(s) with unmapped source suffixes are not compiled: %s", + name, + len(dropped), + ", ".join(sorted(dropped)), + ) + if not lib.sources and not saw_header: + # Matched headers mean header-only; a filter matching nothing is + # a manifest/tree problem (a truly empty tree raises elsewhere) + _LOGGER.warning("Library %s: no source files matched", name) + + +def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: + """Resolve one library's sources, include dirs, and flags (PIO semantics).""" + build = _manifest_build(name, data) + _reject_unsupported_link_fields(name, data) + src_dir = _resolve_src_dir(name, read_path, build) + src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER)) + if not all(isinstance(entry, str) for entry in src_filter): + raise EsphomeError(f"Library {name} has a malformed srcFilter") + lib = ArduinoLibrary(name=name, lib_archive=_resolve_lib_archive(name, data, build)) + # PlatformIO shell-lexes each build.flags entry + include_flags = _classify_build_flags( + name, read_path, lib, lex_build_flags(build.get("flags", []), f"library {name}") + ) + _resolve_include_dirs(name, read_path, lib, build, src_dir, include_flags) + _collect_lib_sources(name, read_path, lib, src_dir, src_filter) + return lib + + +def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: + """A library bundled with the Arduino core, read from the framework tree. + + ``library.json`` wins over ``library.properties`` when both exist, as in + PlatformIO's LibBuilderFactory; only the JSON manifest can carry a + ``build`` section (srcDir, srcFilter, flags). + """ + lib_dir = framework_path / "libraries" / name + manifest_json = lib_dir / "library.json" + if manifest_json.is_file(): + try: + data = parse_library_json(manifest_json) + except ValueError as err: # JSONDecodeError + raise EsphomeError( + f"Bundled library {name} has a corrupt library.json ({err}); " + "the framework install may be incomplete (run 'esphome clean-all')" + ) from err + elif (manifest := lib_dir / "library.properties").is_file(): + data = parse_library_properties(manifest) + else: + # Debug, not warning: the legacy manifest-less layout is legal and + # the 3.1.2 core ships one such library (FSTools), so a warning + # would be unactionable noise on every build using it + _LOGGER.debug("Bundled library %s has no manifest; using defaults", name) + data = {} + if isinstance(data, dict): + # Bundled manifest deps are never walked; make the skip visible + if data.get("dependencies"): + _LOGGER.warning( + "Bundled library %s declares dependencies, which are not " + "resolved automatically; add them with add_library() if needed", + name, + ) + warn_properties_depends(name, data) + build = data.get("build") + if isinstance(build, dict) and build.get("extraScript"): + # Scripts only run on the converted path; building without + # the script's flags would miscompile + raise EsphomeError( + f"Bundled library {name} declares an extraScript, which is " + "not run for bundled libraries" + ) + lib = _library_info(name, lib_dir, data) + _assert_tree_has_code( + name, + lib_dir, + "the framework install may be incomplete (run 'esphome clean-all')", + ) + return lib + + +def _assert_tree_has_code(name: str, root: Path, hint: str) -> None: + """An empty or half-extracted tree can never link; fail by name (a + warning would scroll away and resurface as undefined symbols).""" + if not any( + Path(p).suffix in SRC_FILE_EXTENSIONS + or Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES + for p in walk_files(root) + ): + raise EsphomeError(f"Library {name} has no sources or headers; {hint}") + + +def _external_short_name(name: str) -> str: + """The short library name of a requested spec. + + "owner/Name" and plain names take the last path segment; "Name=" + takes the declared name. Git tails (".git", "#ref") are stripped like + the walk's URL normalization; the comparand is a manifest dependency + name, never a spec. + """ + head, sep, tail = name.partition("=") + if sep and "://" in tail: + return head + short = name.rsplit("/", maxsplit=1)[-1] + return short.partition("#")[0].removesuffix(".git") + + +def _check_unfulfilled_provides( + provided_requests: set[str], satisfied: set[str], still_requested: set[str] +) -> None: + """Fail by name when a walk-skipped dependency was never added. + + An unfulfilled provides() promise only surfaces as undefined symbols + at link. The walk records across re-resolutions, so a name no final + manifest still requests is stale state, never a failure. + """ + if missing := sorted((provided_requests & still_requested) - satisfied): + raise EsphomeError( + "provides() skipped these dependencies but nothing added them: " + f"{', '.join(missing)}; the build is missing libraries" + ) + + +def resolve_libraries( + framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str +) -> list[ArduinoLibrary]: + """Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`. + + ``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would + for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the + shared converter's download cache. + + The returned list is not topologically sorted, so the caller must link + the archives inside one ``--start-group``/``--end-group`` pair (the + bundled-first grouping is incidental). + """ + bundled: list[ArduinoLibrary] = [] + external: list[Library] = [] + # PlatformIO's lib_ignore covers framework-bundled libraries too; the + # shared converter only filters the registry/git ones. + lib_ignore = lib_ignore_set() + # Exact directory names keep membership case-sensitive everywhere + # (an is_dir() probe would match "wire" on macOS/Windows and build + # the bundled Wire twice) + libraries_dir = framework_path / "libraries" + if not libraries_dir.is_dir(): + # A registry fallback would fail later with a misleading + # package-not-found error per bundled name + raise EsphomeError( + f"{libraries_dir} is missing; the framework install may be " + "incomplete (run 'esphome clean-all')" + ) + bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir()) + + def _provided(name: object) -> bool: + return _is_safe_library_name(name) and name in bundled_dir_names + + for library in CORE.platformio_libraries.values(): + if is_lib_ignored(library.name, lib_ignore): + continue + # Bundled only for a bare name with a matching framework dir; pinned + # or unmatched names resolve from the registry, as under PlatformIO. + if not library.repository and not library.version and _provided(library.name): + # Bundled manifest deps are not walked; _bundled_library warns + bundled.append(_bundled_library(framework_path, library.name)) + else: + external.append(library) + + converted: list[ArduinoLibrary] = [] + bundled_names = {lib.name for lib in bundled} + converted_manifest_names: set[str] = set() + # Bundled candidates skipped on purpose (platform filter); the + # provides() reconciliation must count them as satisfied + knowingly_skipped: set[str] = set() + # Dependency names of the manifests actually emitted; a walk recording + # for a since-re-resolved manifest must not fail the reconciliation + final_dep_names: set[str] = set() + # Ordered set of bundled dependency names to add once conversion is done + pending_bundled: dict[str, None] = {} + # Deps matching a separately-requested external are already in the build + # (a duplicate archive means duplicate-symbol link errors) + external_short_names = { + _external_short_name(lib.name) for lib in external if lib.name + } + + def _add_bundled_dependencies(component: ConvertedLibrary) -> None: + # A version-less bare name ("Hash") is a core-bundled library the + # shared converter cannot resolve from the registry + for dep in normalize_dependencies( + component.data.get("dependencies"), component.name + ): + # normalize_dependencies guarantees a non-empty str name + name = dep["name"] + final_dep_names.add(name) + if "/" in name: + owner, _, pkg = name.partition("/") + if _is_safe_library_name(owner) and _is_safe_library_name(pkg): + # Owner-qualified; the converter resolves it from the registry + continue + if not _is_safe_library_name(name): + # The name becomes a path component; never join a traversal + _LOGGER.warning( + "Ignoring malformed dependency entry %r of library %s", + dep, + component.name, + ) + continue + if name in external_short_names: + if _provided(name): + # A bundled copy is suppressed; a coincidental name + # collision would surface as link errors + _LOGGER.warning( + "Dependency %s of %s is assumed satisfied by a " + "requested external library; the bundled copy is " + "not added", + name, + component.name, + ) + else: + _LOGGER.debug( + "Dependency %s of %s assumed satisfied by a requested " + "external library", + name, + component.name, + ) + continue + if name in bundled_names or is_lib_ignored(name, lib_ignore): + continue + if _url_or_none(dep.get("version")) is not None: + # A URL names one specific source; never add the bundled copy + continue + if dep.get("owner") or not _provided(name): + # Only owner-less framework-tree names take the bundled + # copy (PIO's process_dependencies); the walk reports drops + continue + try: + # framework=None: the walk already warned for non-platform + # causes; debug keeps one fault from warning twice (pinned + # by test_nonplatform_rejection_warns_once_through_real_converter) + check_library_data(dep, pio_platform, None) + except IncompatiblePlatform as err: + # A knowing skip (platform filter), not a broken promise + knowingly_skipped.add(name) + _LOGGER.debug("Skip bundled candidate %s: %s", name, err) + continue + except InvalidLibrary as err: + # Malformed manifest data never counts as satisfied; the + # walk owns the warning (see the warns-once test above) + _LOGGER.debug("Skip malformed bundled candidate %s: %s", name, err) + continue + # Deferred: a later manifest name may satisfy this + pending_bundled.setdefault(name) + + def _emit(component: ConvertedLibrary) -> None: + apply_extra_script( + component, board_mcu=lambda: board_mcu, pio_platform=pio_platform + ) + _assert_tree_has_code( + component.get_require_name(), + component.source_dir, + "the download may be incomplete (run 'esphome clean-all')", + ) + if isinstance(manifest_name := component.data.get("name"), str): + converted_manifest_names.add(manifest_name) + lib = _library_info( + component.get_require_name(), component.source_dir, component.data + ) + # Extra-script LINKFLAGS travel outside build.flags; dropping + # them would link wrong with no stated cause + lib.link_flags.extend( + component.data.get(ESPHOME_DATA_KEY, {}).get( + ESPHOME_DATA_LINK_FLAGS_KEY, [] + ) + ) + converted.append(lib) + _add_bundled_dependencies(component) + + backend = LibraryBackend( + platform=pio_platform, + framework="arduino", + emit=_emit, + cache_key=cache_key, + # The walk must not resolve bundled names from the registry; + # _add_bundled_dependencies adds them after emit + provides=_provided, + ) + if external: + convert_libraries(external, backend) + for name in pending_bundled: + if name in converted_manifest_names: + # The converted library is this one; the bundled copy would + # double the archive. Warn like the external_short_names twin. + _LOGGER.warning( + "Dependency %s is assumed satisfied by a converted library's " + "manifest name; the bundled copy is not added", + name, + ) + continue + bundled_names.add(name) + bundled.append(_bundled_library(framework_path, name)) + + _check_unfulfilled_provides( + backend.provided_requests, + bundled_names + | converted_manifest_names + | external_short_names + | knowingly_skipped, + final_dep_names, + ) + + return bundled + converted diff --git a/esphome/arduino8266/__init__.py b/esphome/arduino8266/__init__.py new file mode 100644 index 00000000000..8f403a8553c --- /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 00000000000..663002b3b14 --- /dev/null +++ b/esphome/arduino8266/framework.py @@ -0,0 +1,159 @@ +"""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 packages for 3.0.0, 3.0.1 or 3.1.0 +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 for 3.x cores; 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.major < 3: + raise EsphomeError( + f"Arduino core {ver} is not supported; ESPHome requires core 3.x" + ) + 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 3972d735f55..3296d65af68 100644 --- a/esphome/async_thread.py +++ b/esphome/async_thread.py @@ -11,43 +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 cast + +_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 + + +_runner_ids = count(1) + + +class AsyncDispatchTimeout(TimeoutError): + """The caller stopped waiting; the coroutine was abandoned. + + 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. + """ class AsyncThreadRunner[T](threading.Thread): """Run an async coroutine in a daemon thread and expose its result. - The runner catches all exceptions from the coroutine and stores them in - ``exception`` so ``event`` is always set — this prevents callers waiting - on ``event`` from hanging forever when the coroutine crashes. - - Typical usage:: - - 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 + ``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) + super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}") self._coro_factory = coro_factory 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: # noqa: BLE001 # 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/build_tool.py b/esphome/build_gen/build_tool.py new file mode 100644 index 00000000000..00aa1ec69df --- /dev/null +++ b/esphome/build_gen/build_tool.py @@ -0,0 +1,108 @@ +"""Tiny cross-platform build steps invoked from the generated ninja file. + +Plain script (not ``python -m``): it runs from ninja with whatever Python +started esphome and must not depend on the package being importable. + +Subcommands: + ar remove stale archive, then ``ar rcs`` + copy copy a file + +The ar rspfile carries one object path per line (the generating rule must +use ``$in_newline``, never ``$in``). +""" + +from pathlib import Path +import shutil +import subprocess +import sys + + +def _read_rspfile(rspfile: str) -> list[str]: + r"""The object paths listed in ``rspfile``, unquoted. + + GNU ar treats backslashes in response files as escapes (corrupts + Windows paths), so the caller expands the list into argv; strip the + simple surrounding quote ninja adds to special paths, then undo + ninja's POSIX escape for an embedded quote ('a'\\''b.o' -> a'b.o). + """ + return [ + line[1:-1].replace("'\\''", "'") + if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"" + else line + for line in Path(rspfile).read_text(encoding="utf-8").splitlines() + if line + ] + + +def _run_ar(ar: str, archive: str, rspfile: str) -> int: + # Remove first: ``ar rcs`` replaces members but never drops ones whose + # source was removed from the build, which would leak stale objects. + Path(archive).unlink(missing_ok=True) + objects = _read_rspfile(rspfile) + if not objects: + # An empty archive would "succeed" here and fail far away at link + print(f"ar: no objects listed in {rspfile} for {archive}", file=sys.stderr) + return 1 + # Batch by argv length: expanding the rspfile gives back the Windows + # 32767-char command-line limit it existed to avoid. "rcs" creates, + # "qs" appends; the s keeps the symbol index explicit on every ar. + op = "rcs" + ok = False + try: + while objects: + batch = [objects.pop(0)] + batch_len = len(batch[0]) + while objects and batch_len + len(objects[0]) < 25000: + batch_len += len(objects[0]) + 1 + batch.append(objects.pop(0)) + rc = subprocess.run( + [ar, op, archive, *batch], check=False, close_fds=False + ).returncode + if rc != 0: + return rc + op = "qs" + ok = True + return 0 + finally: + if not ok: + # Any failure (bad exit, missing ar binary, interrupt) must not + # leave a truncated archive behind + Path(archive).unlink(missing_ok=True) + + +def _run_copy(src: str, dst: str) -> int: + try: + shutil.copyfile(src, dst) + except OSError as err: + # Never leave a partially written output (e.g. a firmware image); + # SameFileError means dst IS src, where unlinking destroys the input + if not isinstance(err, shutil.SameFileError): + Path(dst).unlink(missing_ok=True) + print(f"copy: {src} -> {dst} failed: {err}", file=sys.stderr) + return 1 + return 0 + + +# mode -> (handler, expected operand count); surplus argv means a +# mis-specified ninja rule and must error, not silently drop operands +_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)} + + +def main() -> int: + mode = sys.argv[1] if len(sys.argv) > 1 else "" + if entry := _MODES.get(mode): + handler, argc = entry + args = sys.argv[2:] + if len(args) != argc: + print( + f"build_tool {mode}: expected {argc} arguments, got {len(args)}", + file=sys.stderr, + ) + return 1 + return handler(*args) + print(f"unknown build_tool mode: {mode}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index cc2fc5c4cd5..2ef89cf595b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -1,11 +1,18 @@ """ESP-IDF direct build generator for ESPHome.""" import json +import logging from pathlib import Path -from esphome.components.esp32 import get_esp32_variant, idf_version +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, @@ -13,6 +20,8 @@ from esphome.framework_helpers import ( ) from esphome.helpers import mkdir_p, write_file_if_changed +_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(), @@ -26,11 +35,12 @@ idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"" def get_available_components() -> list[str] | None: - """Get list of built-in ESP-IDF components from project_description.json. + """List the built-in ESP-IDF components from ``project_description.json``. - Excludes ``src``, IDF-managed components (``managed_components/``), and - converted PIO libs (``pio_components/``). Returns ``None`` if the build - dir or ``project_description.json`` isn't ready yet. + 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 @@ -41,41 +51,44 @@ def get_available_components() -> list[str] | None: try: 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 IDF-managed and converted-PIO components (external). - comp_dir = info.get("dir", "") - if "managed_components" in comp_dir or "pio_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(minimal: bool = False) -> str: +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}"' + + +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``. """ - # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3) - variant = get_esp32_variant() - idf_target = variant.lower().replace("-", "") + 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 @@ -109,6 +122,15 @@ def get_project_cmakelists(minimal: bool = False) -> str: 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. @@ -119,8 +141,6 @@ def get_project_cmakelists(minimal: bool = False) -> str: # 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). - from esphome.components.esp32 import get_managed_component_require_names - managed_components_property = "\n".join( f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)" for name in get_managed_component_require_names() @@ -131,12 +151,24 @@ def get_project_cmakelists(minimal: bool = False) -> str: # 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(get_available_components() or []) + 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(";")) + ) ) ) @@ -163,6 +195,8 @@ 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} @@ -210,15 +244,27 @@ def get_component_cmakelists() -> str: 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() @@ -236,7 +282,9 @@ 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.""" mkdir_p(CORE.build_path) mkdir_p(CORE.relative_src_path()) @@ -244,7 +292,7 @@ def write_project(minimal: bool = False) -> None: # Write top-level CMakeLists.txt write_file_if_changed( CORE.relative_build_path("CMakeLists.txt"), - get_project_cmakelists(minimal=minimal), + get_project_cmakelists(minimal=minimal, builtin_components=builtin_components), ) # Write component CMakeLists.txt in src/ @@ -252,3 +300,13 @@ def write_project(minimal: bool = False) -> None: CORE.relative_src_path("CMakeLists.txt"), 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 b63c4b733d4..0a12d344a02 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -63,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" diff --git a/esphome/build_helpers/__init__.py b/esphome/build_helpers/__init__.py new file mode 100644 index 00000000000..df956a25094 --- /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 00000000000..5b5c7f247fb --- /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/espidf/idedata.py b/esphome/build_helpers/idedata.py similarity index 51% rename from esphome/espidf/idedata.py rename to esphome/build_helpers/idedata.py index 0047d568e2b..038fe649703 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/build_helpers/idedata.py @@ -1,10 +1,10 @@ -"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``. +"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``. -PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF -toolchain has no such command, but its CMake build emits -``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module -turns that file into the same fields consumers (IDE integration, clang-tidy) -expect: +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}} """ @@ -18,6 +18,19 @@ 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. @@ -30,12 +43,8 @@ _ESPHOME_SRC_MARKER = "/src/esphome/" def _is_esphome_src(file: str) -> bool: - """Whether ``file`` is an ESPHome C++ translation unit. - - ``compile_commands.json`` ``file`` paths use the OS-native separator, so on - Windows they contain backslashes; normalize to ``/`` before testing the - marker, otherwise no source matches and the build-include union is empty. - """ + """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 ) @@ -106,11 +115,8 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]: def _pick_entry(entries: list[dict]) -> dict: - """Pick a representative ESPHome C++ translation unit. - - All ESPHome sources share the same component flags/defines, so any one of - them yields the cxx_path / cxx_flags / defines we need. - """ + """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 @@ -120,25 +126,46 @@ def _pick_entry(entries: list[dict]) -> dict: raise ValueError("no C++ translation unit found in compile_commands.json") -def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: +# 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: - # Include paths in compile_commands are interpreted relative to the - # entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them - # so the cached idedata is usable regardless of the consumer's cwd. - # Emit forward slashes (``normpath`` yields ``\`` on Windows) so the - # paths match the absolute, already-forward-slash entries in the JSON. + # 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] = [] @@ -168,7 +195,7 @@ def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: return cxx_path, defines, includes, cxx_flags -def _get_toolchain_includes(cxx_path: str) -> list[str]: +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"], @@ -219,26 +246,128 @@ def _cc_path_from_cxx(cxx_path: str) -> str: return f"{stem}{suffix}" -def idedata_from_build(compile_commands: Path) -> dict: +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 ESP-IDF compile entry only carries its own component's REQUIRES - include set, 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). + 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")) - cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries)) + 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") - build_includes: dict[str, None] = {} + 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 not _is_esphome_src(entry["file"]): + if entry is representative or not _is_esphome_src(entry["file"]): continue - for inc in _parse_entry(entry)[2]: + 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, @@ -246,6 +375,6 @@ def idedata_from_build(compile_commands: Path) -> dict: "defines": defines, "includes": { "build": list(build_includes), - "toolchain": _get_toolchain_includes(cxx_path), + "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 00000000000..8c25bc95130 --- /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 00000000000..b8881110441 --- /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 00000000000..e7193a8e2af --- /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 0315f9adb1e..f499e0de1bc 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -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,12 +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__) DOMAIN = "bundle" -BUNDLE_EXTENSION = ".esphomebundle.tar.gz" MANIFEST_FILENAME = "manifest.json" CURRENT_MANIFEST_VERSION = 1 MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB @@ -124,6 +125,9 @@ 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; @@ -151,6 +155,30 @@ def add_bundle_file(path: Path) -> None: _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. @@ -306,6 +334,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) @@ -402,6 +431,13 @@ class ConfigBundleCreator: ", ".join(discovered.unresolved), ) 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: @@ -727,11 +763,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) diff --git a/esphome/codegen.py b/esphome/codegen.py index 0694eb4d84b..5debb52b4e2 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401 add, add_build_flag, add_build_unflag, + add_cmake_arg, add_cxx_build_flag, add_define, add_global, @@ -49,11 +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, @@ -75,6 +78,7 @@ from esphome.cpp_types import ( # noqa: F401 StringRef, arduino_json_ns, bool_, + char, const_char_ptr, double, esphome_ns, diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index f4fd2052852..0d855d71db3 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -1,26 +1,193 @@ """Validated-config cache for the upload/logs fast path. -compile dumps the validated config to /storage/.validated.yaml; +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. YAML round-trip (yaml_util.dump/load_yaml) keeps -!lambda/!include/IDs/paths intact; mtime gates staleness. +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.core import CORE +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 +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" @@ -32,45 +199,21 @@ def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool: return False -def save_compiled_config(config: ConfigType) -> None: - """Write the validated-config cache. Always-write so mtime stays fresh. +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). - Mode 0600 because show_secrets=True resolves !secret inline. - Failures are non-fatal: the fast path falls back to read_config. + 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. """ - from esphome import yaml_util - - try: - rendered = yaml_util.dump(config, show_secrets=True) - write_file(compiled_config_path(CORE.config_filename), rendered, private=True) - except Exception as err: # noqa: BLE001 # pylint: disable=broad-except - _LOGGER.debug("Skipping compiled config cache write: %s", err) + if isinstance(value, Lambda): + return {_LAMBDA_KEY: value.value} + return str(value) -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, or the sidecar - is incomplete. - """ - cache_path = compiled_config_path(conf_path.name) - if not _cache_is_fresh(cache_path, conf_path): - return None - - from esphome import yaml_util - - try: - config = yaml_util.load_yaml(cache_path, clear_secrets=False) - except Exception: # noqa: BLE001 # pylint: disable=broad-except - return None - - storage = StorageJSON.load(ext_storage_path(conf_path.name)) - if storage is None: - return None - # apply_to_core assumes a real compile wrote the sidecar; wizard-only - # sidecars leave both of these unset and can't drive upload/logs. - if not storage.core_platform and not storage.target_platform: - return None - storage.apply_to_core() - return config +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 00000000000..e701bd98d4e --- /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/a01nyub/sensor.py b/esphome/components/a01nyub/sensor.py index e5f4f7ef304..f84091d688d 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/sensor.py b/esphome/components/a02yyuw/sensor.py index f0bc59ae6c0..7372f8f7604 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/stepper.py b/esphome/components/a4988/stepper.py index 97f5a6fe0f4..7a19bd550da 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/sensor.py b/esphome/components/absolute_humidity/sensor.py index caaa546e254..84a69dfa236 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/output.py b/esphome/components/ac_dimmer/output.py index 1f35095e0e1..498565b0ea8 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 5e122676cd5..afdfefaba67 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 555d511f6eb..c397e746b0f 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 ( @@ -11,11 +13,13 @@ from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + VARIANT_ESP32S31, get_esp32_variant, ) 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"] @@ -153,6 +157,17 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 9: adc_channel_t.ADC_CHANNEL_8, 10: adc_channel_t.ADC_CHANNEL_9, }, + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s31/include/soc/adc_channel.h + VARIANT_ESP32S31: { + 42: adc_channel_t.ADC_CHANNEL_0, + 43: adc_channel_t.ADC_CHANNEL_1, + 44: adc_channel_t.ADC_CHANNEL_2, + 45: adc_channel_t.ADC_CHANNEL_3, + 46: adc_channel_t.ADC_CHANNEL_4, + 47: adc_channel_t.ADC_CHANNEL_5, + 48: adc_channel_t.ADC_CHANNEL_6, + 49: adc_channel_t.ADC_CHANNEL_7, + }, } # pin to adc2 channel mapping @@ -222,15 +237,27 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { 19: adc_channel_t.ADC_CHANNEL_8, 20: adc_channel_t.ADC_CHANNEL_9, }, + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s31/include/soc/adc_channel.h + VARIANT_ESP32S31: { + 50: adc_channel_t.ADC_CHANNEL_0, + 51: adc_channel_t.ADC_CHANNEL_1, + 52: adc_channel_t.ADC_CHANNEL_2, + 53: adc_channel_t.ADC_CHANNEL_3, + 54: adc_channel_t.ADC_CHANNEL_4, + 55: adc_channel_t.ADC_CHANNEL_5, + 56: adc_channel_t.ADC_CHANNEL_6, + 57: adc_channel_t.ADC_CHANNEL_7, + }, } -def validate_adc_pin(value): +def validate_adc_pin(value: Any) -> ConfigType | str: if str(value).upper() == "VCC": 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_rp2("TEMPERATURE") diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 16c86aee18a..5ca58df10e0 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 a761b377490..c9887cea7cd 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}; @@ -74,9 +74,7 @@ void ADCSensor::setup() { if (this->calibration_handle_ == nullptr) { adc_cali_handle_t handle = nullptr; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 - // RISC-V variants (except C2) and S3 use curve fitting calibration +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -94,7 +92,7 @@ void ADCSensor::setup() { ESP_LOGW(TAG, "Curve fitting calibration failed with error %d, will use uncalibrated readings", err); this->setup_flags_.calibration_complete = false; } -#else // ESP32, ESP32-S2, and ESP32-C2 use line fitting calibration +#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) adc_cali_line_fitting_config_t cali_config = { .unit_id = this->adc_unit_, .atten = this->attenuation_, @@ -112,7 +110,11 @@ void ADCSensor::setup() { ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err); this->setup_flags_.calibration_complete = false; } -#endif // ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3 +#else // No calibration scheme available + (void) handle; + ESP_LOGD(TAG, "No calibration scheme for this variant, readings are uncalibrated"); + this->setup_flags_.calibration_complete = false; +#endif } this->setup_flags_.init_complete = true; @@ -121,23 +123,28 @@ void ADCSensor::setup() { void ADCSensor::dump_config() { LOG_SENSOR("", "ADC Sensor", this); LOG_PIN(" Pin: ", this->pin_); - ESP_LOGCONFIG( - TAG, - " Channel: %d\n" - " Unit: %s\n" - " Attenuation: %s\n" - " Samples: %i\n" - " Sampling mode: %s\n" - " Setup Status:\n" - " Handle Init: %s\n" - " Config: %s\n" - " Calibration: %s\n" - " Overall Init: %s", - this->channel_, LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), - this->autorange_ ? "Auto" : LOG_STR_ARG(attenuation_to_str(this->attenuation_)), this->sample_count_, - LOG_STR_ARG(sampling_mode_to_str(this->sampling_mode_)), - this->setup_flags_.handle_init_complete ? "OK" : "FAILED", this->setup_flags_.config_complete ? "OK" : "FAILED", - this->setup_flags_.calibration_complete ? "OK" : "FAILED", this->setup_flags_.init_complete ? "OK" : "FAILED"); +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) || defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) + const char *calibration_status = this->setup_flags_.calibration_complete ? "OK" : "FAILED"; +#else + const char *calibration_status = "N/A"; // This variant has no calibration scheme +#endif + ESP_LOGCONFIG(TAG, + " Channel: %d\n" + " Unit: %s\n" + " Attenuation: %s\n" + " Samples: %i\n" + " Sampling mode: %s\n" + " Setup Status:\n" + " Handle Init: %s\n" + " Config: %s\n" + " Calibration: %s\n" + " Overall Init: %s", + this->channel_, LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), + this->autorange_ ? "Auto" : LOG_STR_ARG(attenuation_to_str(this->attenuation_)), this->sample_count_, + LOG_STR_ARG(sampling_mode_to_str(this->sampling_mode_)), + this->setup_flags_.handle_init_complete ? "OK" : "FAILED", + this->setup_flags_.config_complete ? "OK" : "FAILED", calibration_status, + this->setup_flags_.init_complete ? "OK" : "FAILED"); LOG_UPDATE_INTERVAL(this); } @@ -184,12 +191,11 @@ float ADCSensor::sample_fixed_attenuation_() { } else { ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); if (this->calibration_handle_ != nullptr) { -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); -#else // Other ESP32 variants use line fitting calibration +#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) adc_cali_delete_scheme_line_fitting(this->calibration_handle_); -#endif // ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3 +#endif this->calibration_handle_ = nullptr; } } @@ -217,10 +223,9 @@ float ADCSensor::sample_autorange_() { // Need to recalibrate for the new attenuation if (this->calibration_handle_ != nullptr) { // Delete old calibration handle -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); -#else +#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) adc_cali_delete_scheme_line_fitting(this->calibration_handle_); #endif this->calibration_handle_ = nullptr; @@ -229,8 +234,7 @@ float ADCSensor::sample_autorange_() { // Create new calibration handle for this attenuation adc_cali_handle_t handle = nullptr; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) adc_cali_curve_fitting_config_t cali_config = {}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -242,7 +246,7 @@ float ADCSensor::sample_autorange_() { err = adc_cali_create_scheme_curve_fitting(&cali_config, &handle); ESP_LOGVV(TAG, "Autorange atten=%d: Calibration handle creation %s (err=%d)", atten, (err == ESP_OK) ? "SUCCESS" : "FAILED", err); -#else +#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) adc_cali_line_fitting_config_t cali_config = { .unit_id = this->adc_unit_, .atten = atten, @@ -264,10 +268,9 @@ float ADCSensor::sample_autorange_() { if (err != ESP_OK) { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); if (handle != nullptr) { -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) adc_cali_delete_scheme_curve_fitting(handle); -#else +#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) adc_cali_delete_scheme_line_fitting(handle); #endif } @@ -286,10 +289,9 @@ float ADCSensor::sample_autorange_() { ESP_LOGVV(TAG, "Autorange atten=%d: UNCALIBRATED FALLBACK - raw=%d -> %.6fV (3.3V ref)", atten, raw, voltage); } // Clean up calibration handle -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 +#if defined(ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED) adc_cali_delete_scheme_curve_fitting(handle); -#else +#elif defined(ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED) adc_cali_delete_scheme_line_fitting(handle); #endif } else { diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index e4f2f82f085..77a192e0254 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 d9b9f50be1f..dfa545b3951 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_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 6cb9ef113f6..ce665e85015 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,26 @@ namespace esphome::adc { -static const char *const TAG = "adc.rp2"; +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(); diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index c3632b00e20..bf45059740f 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 86e2b771abe..8cdea4f01a6 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -3,6 +3,7 @@ import logging import esphome.codegen as cg from esphome.components import sensor, voltage_sampler from esphome.components.esp32 import ( + VARIANT_ESP32S31, get_esp32_variant, include_builtin_idf_component, require_adc_oneshot_iram, @@ -10,8 +11,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,10 +53,18 @@ _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") + # The S31 ADC supports a single attenuation level (SOC_ADC_ATTEN_NUM is 1) + if ( + CORE.is_esp32 + and get_esp32_variant() == VARIANT_ESP32S31 + and config.get(CONF_ATTENUATION, "0db") != "0db" + ): + raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'") + if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1: raise cv.Invalid( "Automatic attenuation cannot be used when multisampling is set" @@ -67,6 +76,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 +129,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 +149,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 +202,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 +218,7 @@ async def to_code(config): zephyr,oversampling = <8>; }}; }}; - """ - ) + """) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index a5281aacc76..684147752d4 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/sensor/__init__.py b/esphome/components/adc128s102/sensor/__init__.py index a65ae9d5374..04589a7ce22 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/addressable_light/display.py b/esphome/components/addressable_light/display.py index 929d45121c3..1db01b40f90 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/sensor.py b/esphome/components/ade7880/sensor.py index beb74d73101..93c279e2359 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 4fc35352f92..71250ac94e3 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/sensor.py b/esphome/components/ade7953_i2c/sensor.py index 4b55acdafa7..8447042d303 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 dce021daad6..6fdf2147f39 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 6d52fc83fdb..b42ee918c57 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/sensor/__init__.py b/esphome/components/ads1115/sensor/__init__.py index afb70d07c82..742f82d302b 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/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 45d47a329e7..956b9a0c1fb 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/sensor/__init__.py b/esphome/components/ads1118/sensor/__init__.py index 33bfe97789f..6bc3baa2e43 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/ags10/sensor.py b/esphome/components/ags10/sensor.py index 6491d7d810d..8606e7c2477 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/sensor.py b/esphome/components/aht10/sensor.py index a5b1cf0ffb8..ae669d00004 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/audio_dac.py b/esphome/components/aic3204/audio_dac.py index b478b573a35..50e2f81f1b6 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/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index 15451107988..44534b80e94 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 881b3e297b5..f2625a78325 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 8105ac32eb1..8fdfeb972f4 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 final : 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 dee26b524aa..58fde11a3d2 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -20,6 +20,8 @@ from esphome.const import ( UNIT_PERCENT, UNIT_VOLT, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@ncareau", "@jeromelaban"] @@ -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/sensor.py b/esphome/components/airthings_wave_mini/sensor.py index f231be66709..9136b333e28 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/sensor.py b/esphome/components/airthings_wave_plus/sensor.py index a12c70f04c2..8ea79e644fc 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/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 048c365616c..92b00d87cb8 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -162,8 +162,9 @@ void Alpha3::send_request_(uint8_t *request, size_t len) { auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len, request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) + if (status) { ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); + } } void Alpha3::update() { diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py index 279ab214cf0..2c1a04ef274 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/sensor.py b/esphome/components/am2315c/sensor.py index ec12ab717ed..febb11409c5 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/sensor.py b/esphome/components/am2320/sensor.py index ed4a5fd9220..ffac0e64077 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 e4ecf1444f4..d1783b77df9 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/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index 2697d364ad8..80341972a95 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/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py index 8c137277551..b2de1d6184c 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 0df7c563136..6da5268432e 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# 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"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b01..0265a350f71 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,13 +1,20 @@ 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"] @@ -74,7 +81,12 @@ SET_FRAME_SCHEMA = cv.Schema( @automation.register_action( "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True ) -async def animation_action_to_code(config, action_id, template_arg, args): +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) diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index 6e382872e2b..b0769bb622e 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -13,7 +13,7 @@ void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); } void Anova::setup() { this->codec_ = make_unique(); - this->current_request_ = 0; + this->poll_step_ = PollStep::IDLE; } void Anova::loop() { @@ -22,6 +22,15 @@ void Anova::loop() { this->disable_loop(); } +void Anova::write_request_(AnovaPacket *pkt) { + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, + pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status) { + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); + } +} + void Anova::control(const ClimateCall &call) { auto mode_val = call.get_mode(); if (mode_val.has_value()) { @@ -38,22 +47,11 @@ void Anova::control(const ClimateCall &call) { ESP_LOGW(TAG, "Unsupported mode: %d", mode); return; } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(pkt); } auto target_temp = call.get_target_temperature(); if (target_temp.has_value()) { - auto *pkt = this->codec_->get_set_target_temp_request(*target_temp); - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } + this->write_request_(this->codec_->get_set_target_temp_request(*target_temp)); } } @@ -62,6 +60,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ case ESP_GATTC_DISCONNECT_EVT: { this->current_temperature = NAN; this->target_temperature = NAN; + this->poll_step_ = PollStep::IDLE; this->publish_state(); break; } @@ -83,8 +82,8 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->node_state = espbt::ClientState::ESTABLISHED; - this->current_request_ = 0; - this->update(); + this->poll_step_ = PollStep::IDLE; + this->update(); // begin the first poll cycle immediately break; } case ESP_GATTC_NOTIFY_EVT: { @@ -101,33 +100,30 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF; } if (this->codec_->has_unit()) { - this->fahrenheit_ = (this->codec_->unit_ == 'f'); - ESP_LOGD(TAG, "Anova units is %s", this->fahrenheit_ ? "fahrenheit" : "celsius"); - this->current_request_++; + ESP_LOGD(TAG, "Anova units is %s", (this->codec_->unit_ == 'f') ? "fahrenheit" : "celsius"); } this->publish_state(); - if (this->current_request_ > 1) { - AnovaPacket *pkt = nullptr; - switch (this->current_request_++) { - case 2: - pkt = this->codec_->get_read_target_temp_request(); - break; - case 3: - pkt = this->codec_->get_read_current_temp_request(); - break; - default: - this->current_request_ = 1; - break; - } - if (pkt != nullptr) { - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - } + // Advance the poll cycle to its next request based on the reply we got. + switch (this->poll_step_) { + case PollStep::SET_UNIT: + this->poll_step_ = PollStep::STATUS; + this->write_request_(this->codec_->get_read_device_status_request()); + break; + case PollStep::STATUS: + this->poll_step_ = PollStep::TARGET; + this->write_request_(this->codec_->get_read_target_temp_request()); + break; + case PollStep::TARGET: + this->poll_step_ = PollStep::CURRENT; + this->write_request_(this->codec_->get_read_current_temp_request()); + break; + case PollStep::CURRENT: + this->poll_step_ = PollStep::IDLE; // full cycle complete + break; + default: + // A reply to an ad-hoc control() write, outside a managed cycle. + break; } break; } @@ -136,27 +132,26 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ } } -void Anova::set_unit_of_measurement(const char *unit) { this->fahrenheit_ = !strncmp(unit, "f", 1); } +void Anova::set_unit_of_measurement(const char *unit) { this->want_fahrenheit_ = !strncmp(unit, "f", 1); } void Anova::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) return; - - if (this->current_request_ < 2) { - AnovaPacket *pkt; - if (this->current_request_ == 0) { - pkt = this->codec_->get_set_unit_request(this->fahrenheit_ ? 'f' : 'c'); - } else { - pkt = this->codec_->get_read_device_status_request(); - } - auto status = - esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, - pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); - } - this->current_request_++; + if (this->poll_step_ != PollStep::IDLE) { + // The previous cycle never finished within a full polling interval -- a + // reply was missed or a write failed. Restart the cycle rather than stall; + // the polling interval itself acts as the timeout. A late reply from the + // abandoned cycle is harmless: state decoding happens on every notify + // regardless of step, and each notify sends at most one follow-up request. + ESP_LOGW(TAG, "[%s] Poll cycle incomplete (step %u); restarting cycle", this->parent_->address_str(), + static_cast(this->poll_step_)); } + // Re-assert the configured unit at the start of every poll cycle, then fall + // through the status/temperature reads via the notification handler. Always + // command the configured unit (want_fahrenheit_) -- never the last value the + // device reported, or a drift to 'c' would lock itself in. + this->poll_step_ = PollStep::SET_UNIT; + this->write_request_(this->codec_->get_set_unit_request(this->want_fahrenheit_ ? 'f' : 'c')); } } // namespace esphome::anova diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h index 49b1100c372..a0fa03df016 100644 --- a/esphome/components/anova/anova.h +++ b/esphome/components/anova/anova.h @@ -37,11 +37,20 @@ class Anova final : public climate::Climate, public esphome::ble_client::BLEClie void set_unit_of_measurement(const char *unit); protected: + // A poll cycle re-asserts the configured unit, then reads device state. + // Re-asserting every cycle prevents the cooker from silently reverting to + // its default (Celsius); previously the unit was only set once on + // connection, so a drift persisted (and corrupted the F/C interpretation of + // subsequent readings) until the BLE link was re-established. + enum class PollStep : uint8_t { SET_UNIT, STATUS, TARGET, CURRENT, IDLE }; + + void write_request_(AnovaPacket *pkt); + std::unique_ptr codec_; void control(const climate::ClimateCall &call) override; uint16_t char_handle_; - uint8_t current_request_; - bool fahrenheit_; + bool want_fahrenheit_{true}; // configured target unit; never overwritten by device replies + PollStep poll_step_{PollStep::IDLE}; }; } // namespace esphome::anova diff --git a/esphome/components/anova/climate.py b/esphome/components/anova/climate.py index e1fd38fddc8..5590b18a83a 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/sensor.py b/esphome/components/apds9306/sensor.py index c3cba96fbfc..4f165eec0b3 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 99e37d3764c..7ac1e5eb326 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/binary_sensor.py b/esphome/components/apds9960/binary_sensor.py index 48e923ab2be..342f6882493 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 468eb0995f1..a75fb79d1bd 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 0719cee352f..272b0786905 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,11 +1,23 @@ -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.const import CONF_DESCRIPTION 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, + new_psk_progmem, + 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 +25,7 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_ENCRYPTION, CONF_EVENT, CONF_ID, CONF_KEY, @@ -30,12 +43,18 @@ from esphome.const import ( CONF_TAG, CONF_THEN, CONF_TRIGGER_ID, + CONF_TYPE, CONF_VARIABLES, ) from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.helpers import fnv1_hash 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 +63,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,9 +127,9 @@ 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_EXAMPLE = "example" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" @@ -129,20 +154,6 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType: return config -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 - - if len(decoded) != 32: - raise cv.Invalid("Encryption key must be base64 and 32 bytes long") - - # Return original data for roundtrip conversion - return value - - CONF_SUPPORTS_RESPONSE = "supports_response" # Enum values in api::enums namespace @@ -217,19 +228,35 @@ 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) +# ESP8266 copies every string of an action into a stack buffer sized by codegen; keep it small +ESP8266_ACTION_STRINGS_MAX_TOTAL = 384 + +VARIABLE_SCHEMA = cv.Schema( + { + cv.Required(CONF_TYPE): cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), + cv.Optional(CONF_DESCRIPTION): cv.string_strict, + cv.Optional(CONF_EXAMPLE): cv.string_strict, + } +) + +# Accepts the plain `name: type` shorthand or the full mapping form +validate_variable = cv.maybe_simple_value(VARIABLE_SCHEMA, key=CONF_TYPE) + + ACTIONS_SCHEMA = automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger), cv.Exclusive(CONF_SERVICE, group_of_exclusion=CONF_ACTION): cv.valid_name, cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.valid_name, + cv.Optional(CONF_DESCRIPTION): cv.string_strict, cv.Optional(CONF_VARIABLES, default={}): cv.Schema( { - cv.validate_id_name: cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), + cv.validate_id_name: validate_variable, } ), # No default - auto-detected by _auto_detect_supports_response @@ -249,18 +276,6 @@ ACTIONS_SCHEMA = automation.validate_automation( ), ) -ENCRYPTION_SCHEMA = cv.Schema( - { - cv.Optional(CONF_KEY): cv.sensitive(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.""" @@ -296,7 +311,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)), @@ -335,10 +350,9 @@ CONFIG_SCHEMA = cv.All( 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 + # Max queued messages per connection, and 2 KB of backlog per slot up + # to 64 KB (a lone message is exempt), before the connection is dropped # Platform defaults based on available RAM and typical message rates: - # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo cv.SplitDefault( CONF_MAX_SEND_QUEUE, esp8266=4, # Limited RAM, need to fail fast @@ -358,6 +372,85 @@ CONFIG_SCHEMA = cv.All( ) +def _has_action_metadata(actions: list[ConfigType]) -> bool: + # Empty strings count as unset, matching _action_strings + return any( + conf.get(CONF_DESCRIPTION) + or any( + var_.get(CONF_DESCRIPTION) or var_.get(CONF_EXAMPLE) + for var_ in conf[CONF_VARIABLES].values() + ) + for conf in actions + ) + + +def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]: + """Strings of one action in the table order UserServiceStatic (user_services.h) expects.""" + # An empty description or example is treated as unset + strings: list[str | None] = [conf[CONF_ACTION]] + if has_metadata: + strings.append(conf.get(CONF_DESCRIPTION) or None) + for name, var_ in conf[CONF_VARIABLES].items(): + strings.append(name) + if has_metadata: + strings += [ + var_.get(CONF_DESCRIPTION) or None, + var_.get(CONF_EXAMPLE) or None, + ] + return strings + + +def _action_strings_size(strings: list[str | None]) -> int: + """Bytes needed to copy every string out of flash, each with its terminator.""" + return sum( + len(string.encode("utf-8")) + 1 for string in strings if string is not None + ) + + +def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: + if not CORE.is_esp8266: + return config + actions = config.get(CONF_ACTIONS, []) + has_metadata = _has_action_metadata(actions) + for conf in actions: + size = _action_strings_size(_action_strings(conf, has_metadata)) + if size > ESP8266_ACTION_STRINGS_MAX_TOTAL: + raise cv.Invalid( + f"Action '{conf[CONF_ACTION]}' has {size} bytes of name, variable name, " + f"description and example text; ESP8266 allows at most " + f"{ESP8266_ACTION_STRINGS_MAX_TOTAL} bytes per action" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings + + +def _add_action_strings( + index: int, strings: list[str | None], interned: dict[str, MockObj] +) -> MockObj: + """Emit the PROGMEM string table for one action. + + Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical + strings are shared between actions through `interned`. + """ + entries: list[MockObj] = [] + for string in strings: + if string is None: + entries.append(cg.nullptr) + continue + if (var := interned.get(string)) is None: + var = interned[string] = cg.progmem_array( + ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), + string, + ) + entries.append(var) + return cg.progmem_array( + ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), + entries, + ) + + @coroutine_with_priority(CoroPriority.WEB) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -377,8 +470,10 @@ async def to_code(config: ConfigType) -> None: cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) + actions = config.get(CONF_ACTIONS, []) + has_user_actions = bool(actions) or config[CONF_CUSTOM_SERVICES] # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled - if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: + if has_user_actions: cg.add_define("USE_API_USER_DEFINED_ACTIONS") # Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration @@ -391,10 +486,17 @@ async def to_code(config: ConfigType) -> None: if config[CONF_HOMEASSISTANT_STATES]: cg.add_define("USE_API_HOMEASSISTANT_STATES") - if actions := config.get(CONF_ACTIONS, []): + scratch_size = 0 + if actions: + # Metadata is compiled in for every action once any action declares it, because the + # string table layout is fixed by the define rather than per action + has_metadata = _has_action_metadata(actions) + if has_metadata: + cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") + interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list - triggers: list[cg.Pvariable] = [] - for conf in actions: + triggers: list[cg.MockObj] = [] + for index, conf in enumerate(actions): func_args: list[tuple[MockObj, str]] = [] service_template_args: list[MockObj] = [] # User service argument types @@ -427,22 +529,23 @@ async def to_code(config: ConfigType) -> None: conf.get(CONF_THEN, []) ) - service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): - if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES: - native = SERVICE_ARG_FALLBACK_TYPES[var_] + var_type = var_[CONF_TYPE] + if has_non_synchronous and var_type in SERVICE_ARG_FALLBACK_TYPES: + native = SERVICE_ARG_FALLBACK_TYPES[var_type] else: - native = SERVICE_ARG_NATIVE_TYPES[var_] + native = SERVICE_ARG_NATIVE_TYPES[var_type] service_template_args.append(native) func_args.append((native, name)) - service_arg_names.append(name) + strings = _action_strings(conf, has_metadata) + table = _add_action_strings(index, strings, interned_strings) + if CORE.is_esp8266: + scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types templ = cg.TemplateArguments(supports_response, *service_template_args) + # Key is hashed here because the name is not readable at runtime on ESP8266 trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], - templ, - conf[CONF_ACTION], - service_arg_names, + conf[CONF_TRIGGER_ID], templ, table, fnv1_hash(conf[CONF_ACTION]) ) triggers.append(trigger) auto = await automation.build_automation(trigger, func_args, conf) @@ -464,6 +567,9 @@ async def to_code(config: ConfigType) -> None: cg.add(auto.add_actions([unregister_action])) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) + if CORE.is_esp8266 and has_user_actions: + # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action + cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") @@ -483,8 +589,7 @@ 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) - cg.add(var.set_noise_psk(list(decoded))) + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired @@ -497,10 +602,6 @@ async def to_code(config: ConfigType) -> None: # 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") @@ -510,6 +611,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: @@ -546,9 +681,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), @@ -581,7 +714,7 @@ async def homeassistant_service_to_code( 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) @@ -609,6 +742,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): @@ -647,7 +782,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( @@ -663,7 +798,7 @@ 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, } ) @@ -676,7 +811,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( 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) @@ -704,6 +844,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 @@ -724,7 +866,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) @@ -740,7 +887,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 @@ -824,18 +971,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 @@ -846,21 +1007,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 c0cfd55063f..b222e7a63fb 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) {} @@ -76,6 +77,7 @@ service APIConnection { rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} + rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} rpc get_yaml(GetYamlRequest) returns (void) {} } @@ -233,6 +235,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: @@ -245,6 +248,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; @@ -282,6 +291,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]; @@ -290,11 +301,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 @@ -307,10 +321,13 @@ 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 @@ -324,6 +341,63 @@ message DeviceInfoResponse { bool has_store_yaml = 27 [(field_ifdef) = "USE_STORE_YAML"]; } +// ==================== 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 { option (id) = 11; option (source) = SOURCE_CLIENT; @@ -937,8 +1011,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 ==================== @@ -964,6 +1042,8 @@ message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; ServiceArgType type = 2; + string description = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; + string example = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; } message ListEntitiesServicesResponse { option (id) = 41; @@ -974,6 +1054,7 @@ message ListEntitiesServicesResponse { fixed32 key = 2 [(force) = true]; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; SupportsResponseType supports_response = 4; + string description = 5 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_METADATA"]; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; @@ -1584,7 +1665,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; @@ -1695,7 +1777,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; @@ -1706,7 +1788,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; @@ -1717,7 +1799,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; } @@ -1761,7 +1843,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; @@ -1770,7 +1852,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; } @@ -1778,7 +1860,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; @@ -1787,7 +1869,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; @@ -1799,7 +1881,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; @@ -1811,7 +1893,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; @@ -1820,7 +1902,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; @@ -1831,7 +1913,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; @@ -1841,7 +1923,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; @@ -1852,13 +1934,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; @@ -1871,7 +1953,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; @@ -1881,7 +1963,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; @@ -1890,7 +1972,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; @@ -1899,7 +1981,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; @@ -1909,7 +1991,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; @@ -1925,7 +2007,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; @@ -2557,6 +2639,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 @@ -2636,7 +2734,8 @@ enum SerialProxyParity { SERIAL_PROXY_PARITY_ODD = 2; } -// Configure UART parameters for a serial proxy instance +// Configure UART parameters for a serial proxy instance. Only the subscribed client may +// configure the port; others are refused with PORT_IN_USE (since API 1.17). message SerialProxyConfigureRequest { option (id) = 138; option (source) = SOURCE_CLIENT; @@ -2662,7 +2761,8 @@ message SerialProxyDataReceived { bytes data = 2; // Raw data received from the serial device } -// Write data to a serial device +// Write data to a serial device. Only the subscribed client may write; writes from +// others are ignored (since API 1.17). message SerialProxyWriteRequest { option (id) = 140; option (source) = SOURCE_CLIENT; @@ -2673,7 +2773,8 @@ message SerialProxyWriteRequest { bytes data = 2; // Raw data to write to the serial device } -// Set modem control pin states (RTS and DTR) +// Set modem control pin states (RTS and DTR). Only the subscribed client may set them; +// others are refused with PORT_IN_USE (since API 1.17). message SerialProxySetModemPinsRequest { option (id) = 141; option (source) = SOURCE_CLIENT; @@ -2700,12 +2801,19 @@ 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 + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17) } enum SerialProxyStatus { @@ -2714,9 +2822,12 @@ 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 +// Generic request message for simple serial proxy operations. FLUSH requires an active +// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17). message SerialProxyRequest { option (id) = 144; option (source) = SOURCE_CLIENT; @@ -2726,7 +2837,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; @@ -2738,11 +2851,34 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL +// activates the port's protocol-aware tap (if one is configured), letting it observe +// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap +// speaks is a property of the device configuration, discoverable from the tap +// component's own API surface. A client that is about to flash firmware selects RAW +// first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_PROTOCOL = 1; +} + +// Only the subscribed client may change the mode; any other caller -- including one that +// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED +// when the port has no protocol-aware tap configured. +message SerialProxySetModeRequest { + option (id) = 152; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== 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 @@ -2754,7 +2890,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; @@ -2769,14 +2905,14 @@ message BluetoothSetConnectionParamsResponse { // connection is ignored; the in-flight transfer continues undisturbed. To // restart a transfer, reconnect. message GetYamlRequest { - option (id) = 149; + option (id) = 154; option (source) = SOURCE_CLIENT; option (ifdef) = "USE_STORE_YAML"; option (no_delay) = true; } message GetYamlResponse { - option (id) = 150; + option (id) = 155; option (source) = SOURCE_SERVER; option (ifdef) = "USE_STORE_YAML"; diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp index 6db18b0365e..62a544b1a41 100644 --- a/esphome/components/api/api_buffer.cpp +++ b/esphome/components/api/api_buffer.cpp @@ -1,13 +1,37 @@ #include "api_buffer.h" +#ifdef ESPHOME_DEBUG_API +#include "esphome/core/log.h" +#endif namespace esphome::api { -void APIBuffer::grow_(size_t n) { - auto new_data = make_buffer(n); - if (this->size_) - std::memcpy(new_data.get(), this->data_.get(), this->size_); - this->data_ = std::move(new_data); +#ifdef ESPHOME_DEBUG_API +void APIBuffer::debug_check_drop_(size_t drop) const { + if (drop > this->size_) { + ESP_LOGE("api.buffer", "drop_front: drop=%zu size=%u", drop, this->size_); + abort(); + } +} +#endif + +bool APIBuffer::grow_(size_t n) { + if (n > MAX_SIZE) + return false; + // realloc extends in place when it can, avoiding the copy + uint8_t *grown = RAMAllocator().reallocate(this->data_.get(), n); + if (grown == nullptr) + return false; + (void) this->data_.release(); // realloc already freed or reused the old block + this->data_.reset(grown); this->capacity_ = n; + return true; +} + +uint8_t *APIBuffer::append(size_t n) { + const size_t old_size = this->size_; + if (!this->resize(old_size + n)) + return nullptr; + return this->data_.get() + old_size; } } // namespace esphome::api diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h index 1d0cccf61ca..7caa68aa4d5 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 @@ -35,24 +25,35 @@ inline std::unique_ptr make_buffer(size_t n) { /// writes in debug builds. class APIBuffer { public: + static constexpr size_t MAX_SIZE = UINT16_MAX; // API frames carry 16 bit lengths 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)); - this->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_ = static_cast(new_size); + return true; + } + /// Grow by n bytes; returns the new bytes, or nullptr on allocation failure. + [[nodiscard]] uint8_t *append(size_t n); + /// Drop the first `drop` bytes, sliding the rest down. Precondition: drop <= size(). + void drop_front(size_t drop) { +#ifdef ESPHOME_DEBUG_API + this->debug_check_drop_(drop); +#endif + this->size_ -= drop; + std::memmove(this->data_.get(), this->data_.get() + drop, this->size_); } 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,10 +65,15 @@ class APIBuffer { } protected: - void grow_(size_t n); - std::unique_ptr data_; - size_t size_{0}; - size_t capacity_{0}; + bool grow_(size_t n); +#ifdef ESPHOME_DEBUG_API + void debug_check_drop_(size_t drop) const; +#endif + // RAMAllocator: PSRAM when available, and it reports failure where + // new (std::nothrow) still aborts on ESP-IDF without exceptions + RAMUniquePtr data_; + uint16_t size_{0}; + uint16_t capacity_{0}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index a79153e9a18..02309e5bc1b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,6 +1,6 @@ #include "api_connection.h" #ifdef USE_API -#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines +#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines #ifdef USE_API_NOISE #include "api_frame_helper_noise.h" #endif @@ -23,6 +23,7 @@ #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 @@ -91,6 +92,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 @@ -155,11 +163,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() { @@ -371,7 +374,10 @@ void APIConnection::check_keepalive_(uint32_t now) { ESP_LOGVV(TAG, "Sending keepalive PING"); PingRequest req; this->flags_.sent_ping = this->send_message(req); - if (!this->flags_.sent_ping) { + if (this->flags_.sent_ping) { + // Quiet for a keepalive period and the ping is on its way: a one-off stall's storage can go + this->helper_->release_overflow_buffer(); + } else { // If we can't send the ping request directly (tx_buffer full), // schedule it at the front of the batch so it will be sent with priority ESP_LOGW(TAG, "Buffer full, ping queued"); @@ -419,15 +425,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 = MAX_INITIAL_PER_BATCH; - 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_(); } } @@ -1106,7 +1112,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(); @@ -1142,6 +1147,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()) @@ -1151,11 +1157,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)) { @@ -1171,15 +1177,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); @@ -1289,31 +1299,28 @@ 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); #if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(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. + // 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 } @@ -1328,6 +1335,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); } @@ -1361,13 +1369,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 @@ -1422,7 +1432,8 @@ 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 @@ -1442,22 +1453,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); @@ -1481,7 +1476,12 @@ void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { } 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 @@ -1640,19 +1640,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) { @@ -1661,69 +1702,92 @@ 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: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + // 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::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &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_MODE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); + return; + } + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + serial_proxy_result_to_status(result)); +} + +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 @@ -1835,15 +1899,17 @@ 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_); HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 14; + resp.api_version_minor = 17; // 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()); @@ -1854,7 +1920,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Acknowledge the hello so the client can read the server name, then request // disconnect with the reason. Authentication is intentionally not completed. this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); - this->send_message(resp); + 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); @@ -1879,9 +1947,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); @@ -1963,8 +2030,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 @@ -1983,6 +2049,7 @@ 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 @@ -2018,6 +2085,36 @@ 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(); @@ -2039,6 +2136,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) { @@ -2117,7 +2219,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, @@ -2128,12 +2232,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 @@ -2151,7 +2277,10 @@ void APIConnection::on_homeassistant_action_response(const HomeassistantActionRe bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg) { NoiseEncryptionSetKeyResponse resp; resp.success = false; - +#ifdef USE_API_NOISE_PSK_FROM_YAML + // A yaml key cannot be changed at runtime, so no decode or save path is built + ESP_LOGW(TAG, "Key set in YAML"); +#else #ifdef USE_PROVISIONING // Refuse to set a key once the provisioning window has closed (defense in depth; // such connections are already rejected at hello). @@ -2161,7 +2290,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } #endif - psk_t psk{}; + noise::psk_t psk{}; if (msg.key_len == 0) { if (this->parent_->clear_noise_psk(true)) { resp.success = true; @@ -2170,7 +2299,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } } else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); - } else if (APINoiseContext::is_all_zeros(psk)) { + } 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"); @@ -2186,6 +2315,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio } #endif } +#endif // USE_API_NOISE_PSK_FROM_YAML return this->send_message(resp); } @@ -2208,11 +2338,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), camera frames (high-frequency noise), @@ -2231,10 +2364,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); @@ -2246,7 +2386,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)) { @@ -2270,30 +2410,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); } @@ -2343,7 +2495,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) { @@ -2358,8 +2514,11 @@ 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; @@ -2422,8 +2581,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 8a7dfa42838..150fe832e68 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -25,6 +25,7 @@ #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 @@ -41,13 +42,23 @@ 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 -static constexpr size_t MAX_INITIAL_PER_BATCH = 34; +// 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; @@ -173,12 +184,7 @@ class APIConnection final : public APIServerConnectionBase { // Returns whether this client has subscribed to Home Assistant actions; the message // is only handed to the send path when subscribed. A true return does not guarantee // delivery - it lets the caller warn when no connected client has the subscription. - bool send_homeassistant_action(const HomeassistantActionRequest &call) { - if (!this->flags_.service_call_subscription) - return false; - this->send_message(call); - return true; - } + 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 @@ -187,6 +193,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); @@ -195,15 +202,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 @@ -243,6 +248,7 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); #endif @@ -270,6 +276,7 @@ class APIConnection final : public APIServerConnectionBase { 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; @@ -324,8 +331,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); } @@ -338,7 +347,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 { @@ -346,22 +357,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) @@ -370,7 +372,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 @@ -389,10 +391,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 @@ -430,7 +433,7 @@ 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. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. // Defined in api_connection_buffer.h (needs APIServer complete). @@ -671,10 +674,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; @@ -684,7 +686,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 @@ -700,7 +702,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) { @@ -765,13 +767,15 @@ 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. @@ -820,7 +824,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 || @@ -834,11 +838,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_(); @@ -846,7 +850,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); @@ -857,6 +861,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 index 1dd8a162e48..08520249bf6 100644 --- a/esphome/components/api/api_connection_buffer.h +++ b/esphome/components/api/api_connection_buffer.h @@ -3,8 +3,8 @@ #include "esphome/core/defines.h" #ifdef USE_API -// Inline APIConnection methods that need APIServer complete. Include this -// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_. +// 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" @@ -41,7 +41,10 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c return 0; auto &shared_buf = conn->parent_->get_shared_buffer_ref(); - shared_buf.resize(shared_buf.size() + to_add); + 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)); @@ -50,5 +53,22 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c 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 74253047665..41d1230aaa6 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -171,8 +171,8 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin return APIError::OK; // 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"); + if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, sent)) { + 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 9cae6ba92ef..a68a0ad0d87 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) {} }; @@ -149,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) @@ -173,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. @@ -187,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 @@ -219,7 +219,10 @@ class APIFrameHelper { if (this->rx_buf_len_ == 0) { this->rx_buf_.release(); } + this->release_overflow_buffer(); } + // Free the send backlog storage once it has drained + void release_overflow_buffer() { this->overflow_buf_.release(); } protected: // Drain backlogged overflow data to the socket and handle errors. @@ -312,7 +315,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 225bac51a62..400cd1d9b86 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_(); @@ -98,12 +67,15 @@ APIError APINoiseFrameHelper::init() { } // init prologue - size_t old_size = prologue_.size(); - prologue_.resize(old_size + PROLOGUE_INIT_LEN); + uint8_t *dst = prologue_.append(PROLOGUE_INIT_LEN); + if (dst == nullptr) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } #ifdef USE_ESP8266 - memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #else - std::memcpy(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN); + std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); #endif state_ = State::CLIENT_HELLO; @@ -194,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) { @@ -208,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; @@ -233,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 @@ -297,14 +272,17 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { return handle_handshake_frame_error_(aerr); } // ignore contents, may be used in future for flags - // Resize for: existing prologue + 2 size bytes + frame data - size_t old_size = this->prologue_.size(); + // Append 2 size bytes + frame data to the prologue size_t rx_size = this->rx_buf_.size(); - this->prologue_.resize(old_size + 2 + rx_size); - this->prologue_[old_size] = (uint8_t) (rx_size >> 8); - this->prologue_[old_size + 1] = (uint8_t) rx_size; + uint8_t *dst = this->prologue_.append(2 + rx_size); + if (dst == nullptr) [[unlikely]] { + state_ = State::FAILED; + return APIError::OUT_OF_MEMORY; + } + dst[0] = (uint8_t) (rx_size >> 8); + dst[1] = (uint8_t) rx_size; if (rx_size > 0) { - std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); + std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } state_ = State::SERVER_HELLO; @@ -348,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_() { @@ -368,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); } @@ -390,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_(); @@ -409,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_(); @@ -490,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 @@ -515,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) @@ -568,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. @@ -590,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_, 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; } @@ -634,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) @@ -651,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; @@ -672,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 b0ba9fd01c7..366751738e6 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,9 +14,9 @@ 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; } @@ -31,7 +31,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { #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: @@ -44,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_(); @@ -52,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 9359f568fbd..d4e3354fa00 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 @@ -171,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 @@ -252,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. @@ -292,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 ea3f6d72807..00e7c7b1bc4 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,7 +22,7 @@ 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 diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h deleted file mode 100644 index 44484ffa2c5..00000000000 --- a/esphome/components/api/api_noise_context.h +++ /dev/null @@ -1,37 +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: - // The all-zeros PSK is reserved: it marks the device as unprovisioned and - // doubles as the well-known provisioning PSK that unprovisioned devices - // accept for Noise handshakes (passive-sniffing protection only, no - // authentication). It is never a valid real key. - static bool is_all_zeros(const psk_t &psk) { - uint8_t acc = 0; - for (uint8_t b : psk) { - acc |= b; - } - return acc == 0; - } - void set_psk(psk_t psk) { - this->psk_ = psk; - this->has_psk_ = !is_all_zeros(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 ac9c4e59cca..66295b3d535 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 e242d4553e4..0b5a874d4b5 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -4,66 +4,88 @@ namespace esphome::api { -APIOverflowBuffer::~APIOverflowBuffer() { - for (auto *entry : this->queue_) { - if (entry != nullptr) - Entry::destroy(entry); - } -} - ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { + // Nested call from inside socket->write(); see draining_ + if (this->draining_) + return 0; + + struct DrainGuard { + APIOverflowBuffer &owner; + ~DrainGuard() { this->owner.draining_ = false; } + } guard{*this}; + this->draining_ = true; + while (this->count_ > 0) { - Entry *front = this->queue_[this->head_]; + uint8_t *msg = this->buf_.data() + this->head_; + size_t len = msg[0] | (msg[1] << 8); - ssize_t sent = socket->write(front->current_data(), front->remaining()); - - if (sent <= 0) { - // -1 = error (caller checks errno for EWOULDBLOCK vs hard error) - // 0 = nothing sent (treat as no progress) + ssize_t sent = socket->write(msg + LEN_PREFIX, len); + if (sent <= 0) + return sent; + if (static_cast(sent) < len) { + // Step past the sent bytes and rewrite the prefix there; it lands on bytes already sent + this->head_ += sent; + len -= sent; + msg += sent; + msg[0] = len; + msg[1] = len >> 8; return sent; } - - if (static_cast(sent) < front->remaining()) { - // Partially sent, update offset and stop - front->offset += static_cast(sent); - return sent; - } - - // Entry fully sent — free it and advance - Entry::destroy(front); - this->queue_[this->head_] = nullptr; - this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; + this->head_ += LEN_PREFIX + len; this->count_--; } - return 0; // All drained + this->head_ = 0; + if (this->release_when_drained_) { + this->release_when_drained_ = false; + this->buf_.release(); + } else { + this->buf_.clear(); + } + return 0; } -bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip) { +bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip) { if (this->count_ >= API_MAX_SEND_QUEUE) return false; - uint16_t buffer_size = total_len - skip; - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0}; - this->queue_[this->tail_] = entry; + const size_t new_len = total_len - skip; + const size_t new_bytes = LEN_PREFIX + new_len; + const size_t live = this->buf_.size() - this->head_; + // A lone message is only bound by the buffer; refusing it would just drop the connection + if (live + new_bytes > (this->count_ > 0 ? MAX_BYTES : MAX_LONE_BYTES)) + return false; - uint16_t to_skip = skip; - uint16_t write_pos = 0; + if (this->buf_.size() + new_bytes > this->buf_.capacity()) { + // Storage would move under an outer drain's write() + if (this->draining_) + return false; + if (this->head_ > 0) { + // Reclaim the sent prefix before growing + this->buf_.drop_front(this->head_); + this->head_ = 0; + } + if (!this->buf_.reserve(reserve_for(live + new_bytes))) + return false; + } - for (int i = 0; i < iovcnt; i++) { - if (to_skip >= iov[i].iov_len) { - to_skip -= static_cast(iov[i].iov_len); + uint8_t *dst = this->buf_.append(new_bytes); + if (dst == nullptr) + return false; + dst[0] = new_len; + dst[1] = new_len >> 8; + dst += LEN_PREFIX; + for (const struct iovec *end = iov + iovcnt; iov != end; iov++) { + if (skip >= iov->iov_len) { + skip -= iov->iov_len; } else { - const uint8_t *src = reinterpret_cast(iov[i].iov_base) + to_skip; - uint16_t len = static_cast(iov[i].iov_len) - to_skip; - std::memcpy(entry->data + write_pos, src, len); - write_pos += len; - to_skip = 0; + const size_t len = iov->iov_len - skip; + std::memcpy(dst, static_cast(iov->iov_base) + skip, len); + dst += len; + skip = 0; } } - 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 19aae680f01..e2e4b9c3c37 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #include #include @@ -8,67 +9,57 @@ #include "esphome/components/socket/headers.h" #include "esphome/components/socket/socket.h" -#include "esphome/core/helpers.h" +#include "api_buffer.h" namespace esphome::api { -/// Circular queue of heap-allocated byte buffers used as a TCP send backlog. -/// -/// Under normal operation this buffer is **never used** — data goes straight -/// from the frame helper to the socket. It only fills when the LWIP TCP -/// send buffer is full (slow client, congested network, heavy logging). -/// The queue drains automatically on subsequent write/loop calls once the -/// socket becomes writable again. -/// -/// Capacity is compile-time-fixed via API_MAX_SEND_QUEUE (set from Python -/// config). If the queue fills completely the connection is marked failed. +/// TCP send backlog, only used when the socket send buffer is full. +/// One contiguous buffer per connection, allocated on the first stall and +/// kept at its high-water mark so a lossy link does not churn the heap. +/// Messages are stored as a 2 byte length prefix plus payload. +/// API_MAX_SEND_QUEUE bounds queued messages and, at 2 KB per slot, queued +/// bytes; exceeding either fails the connection. class APIOverflowBuffer { public: - /// A single heap-allocated send-backlog entry. - /// Lifetime is manually managed — see destroy(). - struct Entry { - uint8_t *data; - uint16_t size; // Total size of the buffer - uint16_t offset; // Current send offset within the buffer - - uint16_t remaining() const { return this->size - this->offset; } - const uint8_t *current_data() const { return this->data + this->offset; } - - /// Free this entry and its data buffer. - static ESPHOME_ALWAYS_INLINE void destroy(Entry *entry) { - delete[] entry->data; - delete entry; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - ~APIOverflowBuffer(); - /// True when no backlogged data is waiting. bool empty() const { return this->count_ == 0; } - /// True when the queue has no room for another entry. - bool full() const { return this->count_ >= API_MAX_SEND_QUEUE; } - - /// Number of entries currently queued. - uint8_t count() const { return this->count_; } - - /// Try to drain queued data to the socket. - /// Returns bytes-written > 0 on success/partial, 0 if all drained or no progress, - /// -1 on error (caller must check errno to distinguish EWOULDBLOCK from hard errors). - /// Callers only need to act on -1; 0 and positive values both mean "no error". - /// Frees entries as they are fully sent. + /// Drain queued messages to the socket. + /// Returns bytes written, 0 for a re-entrant call, -1 on error (check errno + /// for EWOULDBLOCK); callers only need to act on -1. ssize_t try_drain(socket::Socket *socket); - /// 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). - bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); + /// Queue iov data from byte offset `skip` as one message. + /// Returns false when a limit is hit, allocation fails, or storage would move + /// during a drain; the caller should fail the connection. + bool enqueue_iov(const struct iovec *iov, int iovcnt, size_t total_len, size_t skip); + + /// Free the retained storage, now if empty, otherwise once it has drained. + void release() { + if (this->count_ == 0) { + this->buf_.release(); + } else { + this->release_when_drained_ = true; + } + } protected: - std::array queue_{}; - uint8_t head_{0}; - uint8_t tail_{0}; + static constexpr size_t LEN_PREFIX = 2; + static constexpr size_t BYTES_PER_SLOT = 2048; + // Reserve in 256 byte steps so a creeping high-water mark settles quickly + static constexpr size_t GROW_QUANTUM = 256; + // Lone message ceiling, rounded down so reserve_for() never exceeds the buffer limit + static constexpr size_t MAX_LONE_BYTES = APIBuffer::MAX_SIZE & ~(GROW_QUANTUM - 1); + static constexpr size_t MAX_BYTES = std::min(API_MAX_SEND_QUEUE * BYTES_PER_SLOT, MAX_LONE_BYTES); + static constexpr size_t reserve_for(size_t want) { return (want + GROW_QUANTUM - 1) & ~(GROW_QUANTUM - 1); } + + APIBuffer buf_; + uint16_t head_{0}; // offset of the front message's length prefix; bytes before it are sent uint8_t count_{0}; + // socket->write() can re-enter the send path (log from an lwip callback): + // a nested drain makes no progress and a nested enqueue never moves storage + bool draining_ : 1 {false}; + bool release_when_drained_ : 1 {false}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 14442c1be60..a3189a0a896 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -102,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 @@ -247,6 +249,82 @@ uint32_t DeviceInfoResponse::calculate_size() const { #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; +} #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); @@ -1179,12 +1257,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; @@ -1206,12 +1281,24 @@ uint8_t *ListEntitiesServicesArgument::encode(ProtoWriteBuffer &buffer PROTO_ENC 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->type)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->description); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->example); +#endif return pos; } uint32_t ListEntitiesServicesArgument::calculate_size() const { uint32_t size = 0; size += ProtoSize::calc_length(1, this->name.size()); size += this->type ? 2 : 0; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + size += ProtoSize::calc_length(1, this->description.size()); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + size += ProtoSize::calc_length(1, this->example.size()); +#endif return size; } uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { @@ -1222,6 +1309,9 @@ uint8_t *ListEntitiesServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENC ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); } ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->supports_response)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->description); +#endif return pos; } uint32_t ListEntitiesServicesResponse::calculate_size() const { @@ -1234,6 +1324,9 @@ uint32_t ListEntitiesServicesResponse::calculate_size() const { } } size += this->supports_response ? 2 : 0; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + size += ProtoSize::calc_length(1, this->description.size()); +#endif return size; } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, proto_varint_value_t value) { @@ -2254,7 +2347,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); } @@ -2274,7 +2366,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()); @@ -2412,6 +2503,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: @@ -2788,6 +2881,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)); @@ -3871,6 +3966,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 { @@ -4113,12 +4220,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) { @@ -4150,8 +4259,21 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { size += ProtoSize::calc_length(1, this->error_message.size()); return size; } +bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { + switch (field_id) { + case 1: + this->instance = value; + break; + case 2: + this->mode = static_cast(value); + break; + default: + return false; + } + return true; +} #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 b4271eb5c9b..b8c8e5cb776 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -9,6 +9,10 @@ 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 { @@ -225,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, @@ -235,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, @@ -332,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 { @@ -343,6 +354,9 @@ 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, + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, @@ -350,6 +364,12 @@ 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, +}; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_PROTOCOL = 1, }; #endif @@ -396,7 +416,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"); } @@ -414,7 +434,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"); } @@ -433,7 +453,7 @@ class HelloResponse final : public ProtoMessage { }; class DisconnectRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 5; + 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"); } @@ -450,7 +470,7 @@ class DisconnectRequest final : public ProtoDecodableMessage { }; 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"); } @@ -463,7 +483,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"); } @@ -476,7 +496,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"); } @@ -521,6 +541,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 @@ -532,7 +553,7 @@ class SerialProxyInfo final : public ProtoMessage { #endif class DeviceInfoResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 10; + static constexpr uint16_t MESSAGE_TYPE = 10; static constexpr uint16_t ESTIMATED_SIZE = 315; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } @@ -603,9 +624,77 @@ class DeviceInfoResponse final : public ProtoMessage { 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 + 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: +}; 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"); } @@ -619,7 +708,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"); } @@ -636,7 +725,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"); } @@ -655,7 +744,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"); } @@ -675,7 +764,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"); } @@ -693,7 +782,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"); } @@ -715,7 +804,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"); } @@ -735,7 +824,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"); } @@ -755,7 +844,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"); } @@ -783,7 +872,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"); } @@ -802,7 +891,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"); } @@ -829,7 +918,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"); } @@ -873,7 +962,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"); } @@ -893,7 +982,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"); } @@ -912,7 +1001,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"); } @@ -929,7 +1018,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"); } @@ -945,7 +1034,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"); } @@ -963,7 +1052,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"); } @@ -979,7 +1068,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"); } @@ -997,7 +1086,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"); } @@ -1013,7 +1102,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"); } @@ -1036,7 +1125,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"); } @@ -1052,7 +1141,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"); } @@ -1082,7 +1171,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"); } @@ -1113,7 +1202,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"); } @@ -1137,7 +1226,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"); } @@ -1155,7 +1244,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"); } @@ -1173,7 +1262,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"); } @@ -1215,14 +1304,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 @@ -1236,6 +1325,12 @@ class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name{}; enums::ServiceArgType type{}; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + StringRef description{}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + StringRef example{}; +#endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1246,8 +1341,8 @@ class ListEntitiesServicesArgument final : public ProtoMessage { }; class ListEntitiesServicesResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 41; - static constexpr uint8_t ESTIMATED_SIZE = 50; + static constexpr uint16_t MESSAGE_TYPE = 41; + static constexpr uint8_t ESTIMATED_SIZE = 59; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } #endif @@ -1255,6 +1350,9 @@ class ListEntitiesServicesResponse final : public ProtoMessage { uint32_t key{0}; FixedVector args{}; enums::SupportsResponseType supports_response{}; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + StringRef description{}; +#endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1286,7 +1384,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"); } @@ -1313,7 +1411,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"); } @@ -1337,7 +1435,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"); } @@ -1352,7 +1450,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"); } @@ -1374,7 +1472,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"); } @@ -1392,7 +1490,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"); } @@ -1426,7 +1524,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"); } @@ -1454,7 +1552,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"); } @@ -1492,7 +1590,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"); } @@ -1513,7 +1611,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"); } @@ -1534,7 +1632,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"); } @@ -1557,7 +1655,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"); } @@ -1578,7 +1676,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"); } @@ -1595,7 +1693,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"); } @@ -1613,7 +1711,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"); } @@ -1629,7 +1727,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"); } @@ -1646,7 +1744,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"); } @@ -1665,7 +1763,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"); } @@ -1683,7 +1781,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"); } @@ -1699,7 +1797,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"); } @@ -1725,7 +1823,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"); } @@ -1744,7 +1842,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"); } @@ -1760,7 +1858,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"); } @@ -1781,7 +1879,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"); } @@ -1797,7 +1895,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"); } @@ -1829,12 +1927,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; @@ -1847,7 +1944,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"); } @@ -1865,7 +1962,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"); } @@ -1891,7 +1988,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"); } @@ -1919,7 +2016,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"); } @@ -1934,9 +2031,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"); } @@ -1954,7 +2053,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"); } @@ -1973,7 +2072,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"); } @@ -2030,7 +2129,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"); } @@ -2047,7 +2146,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"); } @@ -2063,7 +2162,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"); } @@ -2079,7 +2178,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"); } @@ -2102,7 +2201,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"); } @@ -2122,7 +2221,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"); } @@ -2138,7 +2237,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"); } @@ -2157,7 +2256,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"); } @@ -2174,7 +2273,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"); } @@ -2197,7 +2296,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"); } @@ -2215,7 +2314,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"); } @@ -2233,7 +2332,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"); } @@ -2250,7 +2349,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"); } @@ -2267,7 +2366,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"); } @@ -2285,7 +2384,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"); } @@ -2303,7 +2402,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"); } @@ -2319,9 +2418,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"); } @@ -2339,7 +2440,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"); } @@ -2356,7 +2457,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"); } @@ -2385,7 +2486,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"); } @@ -2405,7 +2506,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"); } @@ -2432,7 +2533,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"); } @@ -2449,7 +2550,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"); } @@ -2471,7 +2572,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"); } @@ -2492,7 +2593,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"); } @@ -2511,7 +2612,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"); } @@ -2557,7 +2658,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"); } @@ -2572,7 +2673,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"); } @@ -2590,7 +2691,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"); } @@ -2607,7 +2708,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"); } @@ -2625,7 +2726,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"); } @@ -2641,7 +2742,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"); } @@ -2661,7 +2762,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"); } @@ -2680,7 +2781,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"); } @@ -2697,7 +2798,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"); } @@ -2716,7 +2817,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"); } @@ -2731,7 +2832,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"); } @@ -2750,7 +2851,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"); } @@ -2770,7 +2871,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"); } @@ -2785,7 +2886,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"); } @@ -2804,7 +2905,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"); } @@ -2824,7 +2925,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"); } @@ -2841,7 +2942,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"); } @@ -2859,7 +2960,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"); } @@ -2878,7 +2979,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"); } @@ -2895,7 +2996,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"); } @@ -2915,7 +3016,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"); } @@ -2930,7 +3031,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"); } @@ -2947,7 +3048,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"); } @@ -2965,7 +3066,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"); } @@ -2981,7 +3082,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"); } @@ -3005,7 +3106,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"); } @@ -3023,7 +3124,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"); } @@ -3041,7 +3142,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"); } @@ -3059,11 +3160,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"); } @@ -3082,7 +3200,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"); } @@ -3108,7 +3226,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"); } @@ -3130,7 +3248,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"); } @@ -3151,7 +3269,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"); } @@ -3171,7 +3289,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"); } @@ -3193,7 +3311,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"); } @@ -3211,7 +3329,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"); } @@ -3227,7 +3345,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"); } @@ -3242,13 +3360,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 @@ -3259,7 +3378,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"); } @@ -3275,7 +3394,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"); } @@ -3292,11 +3411,27 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 152; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } #endif -#ifdef USE_BLUETOOTH_PROXY + uint32_t instance{0}; + enums::SerialProxyMode mode{}; +#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; +}; +#endif +#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"); } @@ -3315,7 +3450,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"); } @@ -3334,7 +3469,7 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #ifdef USE_STORE_YAML class GetYamlResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 150; + static constexpr uint16_t MESSAGE_TYPE = 155; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_yaml_response"); } diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 8ebd60fb5d1..3603fac6d77 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 21c0a6e89e1..52baec32fd0 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -584,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) { @@ -606,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: @@ -814,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) { @@ -836,6 +850,12 @@ 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"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -852,6 +872,20 @@ 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"); + } +} +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_PROTOCOL: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -912,6 +946,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 @@ -991,6 +1026,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #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(": "); + it.dump_to(out); + out.append("\n"); + } +#endif + return out.c_str(); +} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); @@ -1420,7 +1504,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"); @@ -1431,6 +1515,12 @@ const char *ListEntitiesServicesArgument::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesServicesArgument")); dump_field(out, ESPHOME_PSTR("name"), this->name); dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + dump_field(out, ESPHOME_PSTR("description"), this->description); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + dump_field(out, ESPHOME_PSTR("example"), this->example); +#endif return out.c_str(); } const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { @@ -1443,6 +1533,9 @@ const char *ListEntitiesServicesResponse::dump_to(DumpBuffer &out) const { out.append("\n"); } dump_field(out, ESPHOME_PSTR("supports_response"), static_cast(this->supports_response)); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + dump_field(out, ESPHOME_PSTR("description"), this->description); +#endif return out.c_str(); } const char *ExecuteServiceArgument::dump_to(DumpBuffer &out) const { @@ -1893,7 +1986,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); @@ -1956,6 +2048,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); @@ -2127,6 +2221,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)); @@ -2592,6 +2688,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 { @@ -2701,6 +2803,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 { @@ -2717,8 +2820,14 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + 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_service.cpp b/esphome/components/api/api_pb2_service.cpp index 02beb6b8e2b..d9e8fec4508 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -302,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); @@ -313,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); @@ -324,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); @@ -335,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); @@ -346,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); @@ -357,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); @@ -368,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); @@ -379,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")); @@ -694,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); @@ -704,9 +704,27 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_bluetooth_set_connection_params_request(msg); 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; + } +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_request(msg); + break; + } #endif #ifdef USE_STORE_YAML - case 149 /* GetYamlRequest is empty */: { + case 154 /* GetYamlRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_get_yaml_request")); #endif diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 48b038e273a..a47acfac1b1 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,6 +27,8 @@ class APIServerConnectionBase { 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,10 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif +#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 6e3448121c3..78ebe5c38e9 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -41,13 +41,13 @@ void APIServer::setup() { ControllerRegistry::register_controller(this); #ifdef USE_API_NOISE + // Always reserve the slot: flash preferences are positional on esp8266, so + // a yaml key build must keep the layout of a runtime key build uint32_t hash = 88491486UL; - this->noise_pref_ = global_preferences->make_preference(hash, true); - #ifndef USE_API_NOISE_PSK_FROM_YAML - // Only load saved PSK if not set from YAML - if (this->load_and_apply_noise_psk_()) { + // A cleared record loads fine but holds no key + if (this->load_and_apply_noise_psk_() && this->noise_ctx_.has_psk()) { ESP_LOGD(TAG, "Loaded saved Noise PSK"); } #endif @@ -123,7 +123,9 @@ void APIServer::setup() { // Best-effort: if the send buffer is full the reason is dropped, but the // client still learns the window is closed when it reconnects (rejected at // hello) or via the socket close. - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } @@ -394,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 @@ -418,12 +423,6 @@ 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; @@ -434,8 +433,10 @@ void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call // 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"); + ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", + call.is_event ? LOG_STR_LITERAL("event") : LOG_STR_LITERAL("action"), call.service.c_str(), + this->is_connected() ? LOG_STR_LITERAL("client has not subscribed to actions (yet)") + : LOG_STR_LITERAL("no client connected")); } } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -548,11 +549,8 @@ 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 +#ifndef USE_API_NOISE_PSK_FROM_YAML bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active) { if (!this->noise_pref_.save(&new_psk)) { @@ -576,7 +574,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"); + } } }); } @@ -584,22 +584,19 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString } bool APIServer::load_and_apply_noise_psk_() { - SavedNoisePsk saved{}; - if (!this->noise_pref_.load(&saved)) + // Load into a temp so a failed read cannot disturb the key in use + SavedNoisePsk loaded{}; + if (!this->noise_pref_.load(&loaded)) return false; - this->set_noise_psk(saved.psk); + this->saved_psk_ = loaded; + // An unprovisioned device stores the reserved all-zeros key, which is no key + const bool has_key = !noise::NoiseContext::is_all_zeros(this->saved_psk_.psk); + this->noise_ctx_.set_psk(has_key ? this->saved_psk_.psk.data() : nullptr); return true; } -bool APIServer::save_noise_psk(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 - ESP_LOGW(TAG, "Key set in YAML"); - return false; -#else - auto &old_psk = this->noise_ctx_.get_psk(); - if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { +bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) { + if (this->saved_psk_.psk == psk) { ESP_LOGW(TAG, "New PSK matches old"); return true; } @@ -615,15 +612,8 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { } #endif return result; -#endif } bool APIServer::clear_noise_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 - ESP_LOGW(TAG, "Key set in YAML"); - return false; -#else SavedNoisePsk empty_psk{}; bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"), make_active); @@ -635,8 +625,8 @@ bool APIServer::clear_noise_psk(bool make_active) { } #endif return result; -#endif } +#endif // USE_API_NOISE_PSK_FROM_YAML #endif #ifdef USE_HOMEASSISTANT_TIME diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 248b83a0ffa..618ea4eb11a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -5,7 +5,10 @@ #include "api_buffer.h" // Must precede clients_ so APIConnection is complete for default_delete (libc++). #include "api_connection.h" -#include "api_noise_context.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" @@ -37,7 +40,7 @@ class UserServiceDescriptor; #ifdef USE_API_NOISE struct SavedNoisePsk { - psk_t psk; + noise::psk_t psk; } PACKED; // NOLINT #endif @@ -51,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; @@ -63,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; } @@ -73,10 +76,15 @@ 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); +#ifndef USE_API_NOISE_PSK_FROM_YAML + // Runtime key changes exist for the provisioning path only (not lambdas); + // with a yaml key they compile out + 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_; } +#endif + /// psk points at 32 bytes that live in flash for the life of the program + void set_noise_psk(const uint8_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); @@ -272,10 +280,12 @@ class APIServer final : public Component, #endif #ifdef USE_API_NOISE +#ifndef USE_API_NOISE_PSK_FROM_YAML bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, bool make_active); // Load saved PSK from preferences and apply it. Returns true on success. bool load_and_apply_noise_psk_(); +#endif // USE_API_NOISE_PSK_FROM_YAML #endif // USE_API_NOISE #ifdef USE_API_HOMEASSISTANT_STATES // Helper methods to reduce code duplication @@ -354,7 +364,10 @@ class APIServer final : public Component, #endif #ifdef USE_API_NOISE - APINoiseContext noise_ctx_; + noise::NoiseContext noise_ctx_; +#ifndef USE_API_NOISE_PSK_FROM_YAML + SavedNoisePsk saved_psk_{}; // backs noise_ctx_ for a runtime provisioned key +#endif ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 7f07146dba8..5e1c88b2ca3 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -1,177 +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 -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 everything the decoder can raise. aioesphomeapi isolates - exceptions raised by log handlers, so an escaping one no longer - kills the session, but it does log a full traceback per line. A - crash dump carries a PC line plus one per backtrace frame, so the - tracebacks bury the dump the user is trying to read. Decoding is a - diagnostic nicety; nothing it raises is worth that noise. - 2. Disable decoding after the first failure. _decode_pc shells out to - the toolchain to resolve addr2line, 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. This only works if every - failure is caught, which is why 1 is not narrowed to EsphomeError. - """ - - 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 Exception as exc: # noqa: BLE001 # pylint: disable=broad-except - 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.debug("Stack-trace decoding failed", exc_info=True) - _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 - provide_time=False, - ) - - # 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 = 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().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, - ) - 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/list_entities.cpp b/esphome/components/api/list_entities.cpp index f9e645b5067..507b098fb48 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -95,9 +95,18 @@ 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); + UserActionScratch scratch; + auto resp = service->encode_list_service_response(scratch); + 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 f058f6af222..a226e080e8c 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.cpp b/esphome/components/api/user_services.cpp index 28a43c656c3..fad3cde29b9 100644 --- a/esphome/components/api/user_services.cpp +++ b/esphome/components/api/user_services.cpp @@ -1,9 +1,52 @@ #include "user_services.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" namespace esphome::api { +StringRef UserServiceStatic::str_(size_t idx, std::span &scratch) const { + const char *s = progmem_read_ptr(&this->strings_[idx]); + if (s == nullptr) + return {}; +#ifdef USE_ESP8266 + // Codegen sizes the scratch buffer for the largest service; the bound only guards other callers + if (scratch.empty()) + return {}; + size_t len = strnlen_P(s, scratch.size() - 1); + progmem_memcpy(scratch.data(), s, len); + scratch[len] = '\0'; + StringRef ref(scratch.data(), len); + scratch = scratch.subspan(len + 1); + return ref; +#else + return StringRef(s); +#endif +} + +ListEntitiesServicesResponse UserServiceStatic::encode_list_service_response_( + std::span arg_types, std::span scratch) const { + ListEntitiesServicesResponse msg; + msg.name = this->str_(0, scratch); + msg.key = this->key_; + msg.supports_response = this->supports_response_; +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + msg.description = this->str_(1, scratch); +#endif + msg.args.init(arg_types.size()); + for (size_t i = 0; i < arg_types.size(); i++) { + size_t base = USER_ACTION_HEADER_STRINGS + i * USER_ACTION_ARG_STRINGS; + auto &arg = msg.args.emplace_back(); + arg.type = arg_types[i]; + arg.name = this->str_(base, scratch); +#ifdef USE_API_USER_DEFINED_ACTION_METADATA + arg.description = this->str_(base + 1, scratch); + arg.example = this->str_(base + 2, scratch); +#endif + } + return msg; +} + template<> bool get_execute_arg_value(const ExecuteServiceArgument &arg) { return arg.bool_; } template<> int32_t get_execute_arg_value(const ExecuteServiceArgument &arg) { if (arg.legacy_int != 0) diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index ea57d0944bc..3b17bdb7bcd 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -19,7 +20,9 @@ class APIServer; class UserServiceDescriptor { public: - virtual ListEntitiesServicesResponse encode_list_service_response() = 0; + /// Build the list-entities message. On ESP8266 the strings live in PROGMEM and are copied into + /// `scratch`, so the returned message is only valid while `scratch` is; other platforms ignore it. + virtual ListEntitiesServicesResponse encode_list_service_response(std::span scratch) = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -34,29 +37,51 @@ template T get_execute_arg_value(const ExecuteServiceArgument &arg); template enums::ServiceArgType to_service_arg_type(); -// Base class for YAML-defined services (most common case) -// Stores only pointers to string literals in flash - no heap allocation -template class UserServiceBase : public UserServiceDescriptor { - public: - UserServiceBase(const char *name, const std::array &arg_names, - enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE) - : name_(name), arg_names_(arg_names), supports_response_(supports_response) { - this->key_ = fnv1_hash(name); - } +// Scratch buffer list-entities hands to encode_list_service_response(); only ESP8266 copies into it +#ifdef USE_ESP8266 +using UserActionScratch = std::array; +#else +using UserActionScratch = std::array; +#endif - ListEntitiesServicesResponse encode_list_service_response() override { - ListEntitiesServicesResponse msg; - msg.name = StringRef(this->name_); - msg.key = this->key_; - msg.supports_response = this->supports_response_; +// Non-template base for YAML-defined services so the list-entities encoder is compiled once. +// All strings live in one PROGMEM pointer table emitted by codegen (see _action_strings in +// __init__.py), so each service costs a single pointer of RAM. Layout: the action name, then +// each argument name; with USE_API_USER_DEFINED_ACTION_METADATA the action description follows +// the name and every argument is (name, description, example). Unset metadata is nullptr. +#ifdef USE_API_USER_DEFINED_ACTION_METADATA +static constexpr size_t USER_ACTION_HEADER_STRINGS = 2; +static constexpr size_t USER_ACTION_ARG_STRINGS = 3; +#else +static constexpr size_t USER_ACTION_HEADER_STRINGS = 1; +static constexpr size_t USER_ACTION_ARG_STRINGS = 1; +#endif +class UserServiceStatic : public UserServiceDescriptor { + public: + UserServiceStatic(const char *const *strings, uint32_t key, + enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE) + : strings_(strings), key_(key), supports_response_(supports_response) {} + + protected: + ListEntitiesServicesResponse encode_list_service_response_(std::span arg_types, + std::span scratch) const; + /// Reference table entry `idx`; nullptr gives an empty StringRef. + /// On ESP8266 the bytes are copied out of PROGMEM into `scratch` with a terminator, and the span + /// is advanced past the copy. + StringRef str_(size_t idx, std::span &scratch) const; + + const char *const *strings_; // PROGMEM pointer table, read with progmem_read_ptr() + uint32_t key_; + enums::SupportsResponseType supports_response_; +}; + +template class UserServiceBase : public UserServiceStatic { + public: + using UserServiceStatic::UserServiceStatic; + + ListEntitiesServicesResponse encode_list_service_response(std::span scratch) override { std::array arg_types = {to_service_arg_type()...}; - msg.args.init(sizeof...(Ts)); - for (size_t i = 0; i < sizeof...(Ts); i++) { - auto &arg = msg.args.emplace_back(); - arg.type = arg_types[i]; - arg.name = StringRef(this->arg_names_[i]); - } - return msg; + return this->encode_list_service_response_(arg_types, scratch); } bool execute_service(const ExecuteServiceRequest &req) override { @@ -89,12 +114,6 @@ template class UserServiceBase : public UserServiceDescriptor { void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence /*type*/) { this->execute(call_id, return_response, (get_execute_arg_value(args[S]))...); } - - // Pointers to string literals in flash - no heap allocation - const char *name_; - std::array arg_names_; - uint32_t key_{0}; - enums::SupportsResponseType supports_response_{enums::SUPPORTS_RESPONSE_NONE}; }; // Separate class for custom_api_device services (rare case) @@ -106,7 +125,7 @@ template class UserServiceDynamic : public UserServiceDescriptor this->key_ = fnv1_hash(this->name_.c_str()); } - ListEntitiesServicesResponse encode_list_service_response() override { + ListEntitiesServicesResponse encode_list_service_response(std::span /*scratch*/) override { ListEntitiesServicesResponse msg; msg.name = StringRef(this->name_); msg.key = this->key_; @@ -167,8 +186,8 @@ template 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) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_NONE) {} protected: void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { this->trigger(x...); } @@ -179,8 +198,8 @@ template 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) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_OPTIONAL) {} protected: void execute(uint32_t call_id, bool return_response, Ts... x) override { @@ -193,8 +212,8 @@ template 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) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_ONLY) {} protected: void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } @@ -205,8 +224,8 @@ template 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) {} + UserServiceTrigger(const char *const *strings, uint32_t key) + : UserServiceBase(strings, key, enums::SUPPORTS_RESPONSE_STATUS) {} protected: void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index 4bb964d5ee0..e78f301b30d 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -23,8 +23,10 @@ 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"); + ESP_LOGCONFIG(TAG, " Calculation Type: %s", + this->aqi_calc_type_ == AQI_TYPE ? LOG_STR_LITERAL("AQI") : LOG_STR_LITERAL("CAQI")); + ESP_LOGCONFIG(TAG, " Extended Range: %s", + this->extended_range_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled")); if (this->pm_2_5_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str()); } diff --git a/esphome/components/aqi/sensor.py b/esphome/components/aqi/sensor.py index 9c361560df4..a98d977227c 100644 --- a/esphome/components/aqi/sensor.py +++ b/esphome/components/aqi/sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( DEVICE_CLASS_AQI, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns @@ -16,7 +17,7 @@ DEPENDENCIES = ["sensor"] AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component) -def _validate_extended_range(config): +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'. " @@ -48,7 +49,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) diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index 70015c53b97..bd02d22d1b3 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 10004e69dc9..929b653294c 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 9b43155563d..b727b8fdb9f 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 09b588cb0cc..83924de760f 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_spi/__init__.py b/esphome/components/as3935_spi/__init__.py index f4cf07a906b..332a51c7a9f 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/as5600/__init__.py b/esphome/components/as5600/__init__.py index c05e5563762..780712c3bd0 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,17 +90,17 @@ 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) @@ -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/sensor/__init__.py b/esphome/components/as5600/sensor/__init__.py index cf67a3f2035..847b89f1214 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/as7341/sensor.py b/esphome/components/as7341/sensor.py index 8b6cf610286..f70c5e999fd 100644 --- a/esphome/components/as7341/sensor.py +++ b/esphome/components/as7341/sensor.py @@ -9,6 +9,7 @@ from esphome.const import ( ICON_BRIGHTNESS_5, STATE_CLASS_MEASUREMENT, ) +from esphome.types import ConfigType CODEOWNERS = ["@mrgnr"] DEPENDENCIES = ["i2c"] @@ -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 22d544ba374..31007022d56 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"] @@ -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 diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 5031b72cceb..193e62f6157 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/switch/__init__.py b/esphome/components/at581x/switch/__init__.py index 8e1b82b356f..7e45ed89ec0 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/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 7b5cdcfa208..22cb2b31504 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."); @@ -132,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 0f472c11b9f..3f5ca4c784e 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 final : 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; } @@ -40,11 +38,9 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT uint8_t last_frame_count_{0}; - optional parse_header_(const esp32_ble_tracker::ServiceData &service_data); + 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 5286d29d1bd..184b2e87339 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/sensor.py b/esphome/components/atm90e26/sensor.py index 5941cb35b42..87db214233a 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.cpp b/esphome/components/atm90e32/atm90e32.cpp index d948b3741df..23701e78348 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -9,6 +9,10 @@ namespace esphome::atm90e32 { static const char *const TAG = "atm90e32"; +static const LogString *offset_calibration_name(bool power_offsets) { + return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); +} + static uint32_t pref_hash(const char *prefix, const char *name_space) { auto hash = fnv1_hash(prefix); return fnv1_hash_extend(hash, name_space); @@ -203,13 +207,12 @@ void ATM90E32Component::setup() { // Initialize flash storage for power offset calibrations uint32_t po_hash = pref_hash("_power_offset_calibration_", cs); - this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); + this->power_offset_pref_ = global_preferences->make_preference(po_hash, true); bool migrated_power_offset = false; if (has_distinct_legacy_namespace) { uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs); - auto legacy_power_offset_pref = - global_preferences->make_preference(legacy_po_hash, true); - PowerOffsetCalibration power_offset_data[3]{}; + auto legacy_power_offset_pref = global_preferences->make_preference(legacy_po_hash, true); + OffsetCalibration power_offset_data[3]{}; int migration_status = migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data); migrated_power_offset = migration_status > 0; @@ -224,20 +227,20 @@ void ATM90E32Component::setup() { global_preferences->sync(); } - this->restore_offset_calibrations_(); - this->restore_power_offset_calibrations_(); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); + this->restore_offset_calibrations_(OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { this->write16_(this->voltage_offset_registers[phase], - static_cast(this->offset_phase_[phase].voltage_offset_)); + static_cast(this->offset_phase_[phase].first_offset)); this->write16_(this->current_offset_registers[phase], - static_cast(this->offset_phase_[phase].current_offset_)); + static_cast(this->offset_phase_[phase].second_offset)); this->write16_(this->power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].active_power_offset)); + static_cast(this->power_offset_phase_[phase].first_offset)); this->write16_(this->reactive_power_offset_registers[phase], - static_cast(this->power_offset_phase_[phase].reactive_power_offset)); + static_cast(this->power_offset_phase_[phase].second_offset)); } } @@ -317,8 +320,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].voltage_offset_, - this->config_offset_phase_[phase].current_offset_, this->offset_phase_[phase].current_offset_); + this->config_offset_phase_[phase].first_offset, this->offset_phase_[phase].first_offset, + this->config_offset_phase_[phase].second_offset, this->offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -335,10 +338,8 @@ void ATM90E32Component::log_calibration_status_() { cs); for (uint8_t phase = 0; phase < 3; ++phase) { ESP_LOGW(TAG, "[CALIBRATION][%s] | %c | %6d | %6d | %6d | %6d |", cs, 'A' + phase, - this->config_power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].active_power_offset, - this->config_power_offset_phase_[phase].reactive_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->config_power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].first_offset, + this->config_power_offset_phase_[phase].second_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGW(TAG, "[CALIBRATION][%s] ===============================================================================", cs); @@ -372,7 +373,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\\n", cs); } @@ -385,8 +386,7 @@ void ATM90E32Component::log_calibration_status_() { ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); } @@ -756,36 +756,68 @@ void ATM90E32Component::save_gain_calibration_to_memory_() { } } -void ATM90E32Component::save_offset_calibration_to_memory_() { +void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); - bool success = this->offset_pref_.save(&this->offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_offset_calibration_ = true; - for (bool &phase : this->offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save offset calibration to memory!", cs); - } -} + const LogString *name = offset_calibration_name(power_offsets); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; -void ATM90E32Component::save_power_offset_calibration_to_memory_() { - const char *cs = this->get_calibration_id_(); - bool success = this->power_offset_pref_.save(&this->power_offset_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - this->restored_power_offset_calibration_ = true; - for (bool &phase : this->power_offset_calibration_mismatch_) - phase = false; - ESP_LOGI(TAG, "[CALIBRATION][%s] Power offset calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save power offset calibration to memory!", cs); + const bool writes_verified = this->verify_offset_writes_(type); + bool saved = false; + bool synced = false; + if (writes_verified) { + saved = preference->save(offsets); + synced = global_preferences->sync(); } + + if (writes_verified && saved && synced) { + this->using_saved_calibrations_ = true; + *has_stored = true; + *restored = true; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs, + LOG_STR_ARG(name), LOG_STR_ARG(name)); + return; + } + + if (writes_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name)); + } + + for (uint8_t phase = 0; phase < 3; phase++) { + this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); + } + const bool rollback_verified = this->verify_offset_writes_(type); + + bool rollback_persisted = false; + if (writes_verified) { + OffsetCalibration rollback[3]{}; + prepare_offset_rollback(previous, previous_restored, rollback); + const bool rollback_saved = preference->save(&rollback); + const bool rollback_synced = global_preferences->sync(); + rollback_persisted = rollback_saved && rollback_synced; + if (!rollback_saved || !rollback_synced) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name)); + } + } + + *restored = previous_restored; + if (rollback_persisted) + *has_stored = previous_restored; + this->using_saved_calibrations_ = previous_using_saved; + if (!rollback_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs, + LOG_STR_ARG(name)); + return; + } + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name)); } void ATM90E32Component::run_offset_calibrations() { @@ -803,11 +835,16 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]}; + const bool previous_restored = this->restored_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); int16_t current_offset = calibrate_offset(phase, false); - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); @@ -815,7 +852,8 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); - this->save_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); } void ATM90E32Component::run_power_offset_calibrations() { @@ -834,18 +872,25 @@ void ATM90E32Component::run_power_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1], + this->power_offset_phase_[2]}; + const bool previous_restored = this->restored_power_offset_calibration_; + const bool previous_using_saved = this->using_saved_calibrations_; + for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); int16_t reactive_offset = calibrate_power_offset(phase, true); - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->save_power_offset_calibration_to_memory_(); + this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } void ATM90E32Component::write_gains_to_registers_() { @@ -859,35 +904,26 @@ void ATM90E32Component::write_gains_to_registers_() { this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } -void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset) { - // Save to runtime - this->offset_phase_[phase].voltage_offset_ = voltage_offset; - this->phase_[phase].voltage_offset_ = voltage_offset; +void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + OffsetCalibration &offsets = power_offsets ? this->power_offset_phase_[phase] : this->offset_phase_[phase]; + offsets.first_offset = first_offset; + offsets.second_offset = second_offset; + if (power_offsets) { + this->phase_[phase].active_power_offset_ = first_offset; + this->phase_[phase].reactive_power_offset_ = second_offset; + } else { + this->phase_[phase].voltage_offset_ = first_offset; + this->phase_[phase].current_offset_ = second_offset; + } - // Save to flash-storable struct - this->offset_phase_[phase].current_offset_ = current_offset; - this->phase_[phase].current_offset_ = current_offset; - - // Write to registers + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(voltage_offset_registers[phase], static_cast(voltage_offset)); - this->write16_(current_offset_registers[phase], static_cast(current_offset)); - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); -} - -void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset) { - // Save to runtime - this->phase_[phase].active_power_offset_ = p_offset; - this->phase_[phase].reactive_power_offset_ = q_offset; - - // Save to flash-storable struct - this->power_offset_phase_[phase].active_power_offset = p_offset; - this->power_offset_phase_[phase].reactive_power_offset = q_offset; - - // Write to registers - this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(this->power_offset_registers[phase], static_cast(p_offset)); - this->write16_(this->reactive_power_offset_registers[phase], static_cast(q_offset)); + this->write16_(first_registers[phase], static_cast(first_offset)); + this->write16_(second_registers[phase], static_cast(second_offset)); this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } @@ -947,89 +983,78 @@ void ATM90E32Component::restore_gain_calibrations_() { ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); } -void ATM90E32Component::restore_offset_calibrations_() { +void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; const char *cs = this->get_calibration_id_(); + const LogString *name = power_offsets ? LOG_STR("power offset") : LOG_STR("offset"); + OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; + OffsetCalibration(*config_offsets)[3] = + power_offsets ? &this->config_power_offset_phase_ : &this->config_offset_phase_; + ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; + bool *has_stored = + power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; + bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; + bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; + const bool *has_first = power_offsets ? this->has_config_active_power_offset_ : this->has_config_voltage_offset_; + const bool *has_second = power_offsets ? this->has_config_reactive_power_offset_ : this->has_config_current_offset_; + for (uint8_t i = 0; i < 3; ++i) - this->config_offset_phase_[i] = this->offset_phase_[i]; - - bool have_data = this->offset_pref_.load(&this->offset_phase_); + (*config_offsets)[i] = (*offsets)[i]; + const bool have_data = preference->load(offsets); bool all_zero = true; if (have_data) { - for (auto &phase : this->offset_phase_) { - if (phase.voltage_offset_ != 0 || phase.current_offset_ != 0) { + for (const auto &phase : *offsets) { + if (phase.first_offset != 0 || phase.second_offset != 0) { all_zero = false; break; } } } - if (have_data && !all_zero) { - this->restored_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; phase++) { - auto &offset = this->offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_voltage_offset_[phase] && - offset.voltage_offset_ != this->config_offset_phase_[phase].voltage_offset_) - mismatch = true; - if (this->has_config_current_offset_[phase] && - offset.current_offset_ != this->config_offset_phase_[phase].current_offset_) - mismatch = true; - if (mismatch) - this->offset_calibration_mismatch_[phase] = true; + *has_stored = have_data && !all_zero; + *restored = false; + for (uint8_t phase = 0; phase < 3; phase++) { + mismatches[phase] = false; + if (*has_stored) { + mismatches[phase] = + (has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || + (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset); } - } else { + } + + if (!*has_stored) { for (uint8_t phase = 0; phase < 3; phase++) - this->offset_phase_[phase] = this->config_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored offset calibrations found. Using default values.", cs); + (*offsets)[phase] = (*config_offsets)[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored %s calibrations found. Using default values.", cs, LOG_STR_ARG(name)); } for (uint8_t phase = 0; phase < 3; phase++) { - write_offsets_to_registers_(phase, this->offset_phase_[phase].voltage_offset_, - this->offset_phase_[phase].current_offset_); + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); } -} - -void ATM90E32Component::restore_power_offset_calibrations_() { - const char *cs = this->get_calibration_id_(); - for (uint8_t i = 0; i < 3; ++i) - this->config_power_offset_phase_[i] = this->power_offset_phase_[i]; - - bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_); - - bool all_zero = true; - if (have_data) { - for (auto &phase : this->power_offset_phase_) { - if (phase.active_power_offset != 0 || phase.reactive_power_offset != 0) { - all_zero = false; - break; - } - } + const bool initial_values_verified = this->verify_offset_writes_(type); + if (initial_values_verified) { + const auto state = resolve_offset_restore_state(*has_stored, true, false); + *restored = state.restored; + ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name)); + return; } - if (have_data && !all_zero) { - this->restored_power_offset_calibration_ = true; - for (uint8_t phase = 0; phase < 3; ++phase) { - auto &offset = this->power_offset_phase_[phase]; - bool mismatch = false; - if (this->has_config_active_power_offset_[phase] && - offset.active_power_offset != this->config_power_offset_phase_[phase].active_power_offset) - mismatch = true; - if (this->has_config_reactive_power_offset_[phase] && - offset.reactive_power_offset != this->config_power_offset_phase_[phase].reactive_power_offset) - mismatch = true; - if (mismatch) - this->power_offset_calibration_mismatch_[phase] = true; - } + this->using_saved_calibrations_ = false; + for (uint8_t phase = 0; phase < 3; phase++) + mismatches[phase] = false; + for (uint8_t phase = 0; phase < 3; phase++) { + (*offsets)[phase] = (*config_offsets)[phase]; + this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); + } + const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type)); + *restored = state.restored; + if (state.values_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, + LOG_STR_ARG(name)); } else { - for (uint8_t phase = 0; phase < 3; ++phase) - this->power_offset_phase_[phase] = this->config_power_offset_phase_[phase]; - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored power offsets found. Using default values.", cs); - } - - for (uint8_t phase = 0; phase < 3; ++phase) { - write_power_offsets_to_registers_(phase, this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs, + LOG_STR_ARG(name)); } } @@ -1084,14 +1109,14 @@ void ATM90E32Component::clear_gain_calibrations() { void ATM90E32Component::clear_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_offset_calibration_) { + if (!this->has_stored_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_voltage | offset_current |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->offset_phase_[phase].voltage_offset_, this->offset_phase_[phase].current_offset_); + this->offset_phase_[phase].first_offset, this->offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); return; @@ -1104,10 +1129,11 @@ void ATM90E32Component::clear_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = - this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].voltage_offset_ : 0; + this->has_config_voltage_offset_[phase] ? this->config_offset_phase_[phase].first_offset : 0; int16_t current_offset = - this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].current_offset_ : 0; - this->write_offsets_to_registers_(phase, voltage_offset, current_offset); + this->has_config_current_offset_[phase] ? this->config_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, voltage_offset, current_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, voltage_offset, current_offset); } @@ -1117,6 +1143,7 @@ void ATM90E32Component::clear_offset_calibrations() { this->offset_pref_.save(&zero_offsets); // Clear stored values in flash global_preferences->sync(); + this->has_stored_offset_calibration_ = false; this->restored_offset_calibration_ = false; for (bool &phase : this->offset_calibration_mismatch_) phase = false; @@ -1126,15 +1153,14 @@ void ATM90E32Component::clear_offset_calibrations() { void ATM90E32Component::clear_power_offset_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->restored_power_offset_calibration_) { + if (!this->has_stored_power_offset_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | offset_active_power | offset_reactive_power |", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); for (uint8_t phase = 0; phase < 3; phase++) { ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, - this->power_offset_phase_[phase].active_power_offset, - this->power_offset_phase_[phase].reactive_power_offset); + this->power_offset_phase_[phase].first_offset, this->power_offset_phase_[phase].second_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); return; @@ -1147,20 +1173,21 @@ void ATM90E32Component::clear_power_offset_calibrations() { for (uint8_t phase = 0; phase < 3; phase++) { int16_t active_offset = - this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].active_power_offset : 0; - int16_t reactive_offset = this->has_config_reactive_power_offset_[phase] - ? this->config_power_offset_phase_[phase].reactive_power_offset - : 0; - this->write_power_offsets_to_registers_(phase, active_offset, reactive_offset); + this->has_config_active_power_offset_[phase] ? this->config_power_offset_phase_[phase].first_offset : 0; + int16_t reactive_offset = + this->has_config_reactive_power_offset_[phase] ? this->config_power_offset_phase_[phase].second_offset : 0; + this->write_offsets_to_registers_(phase, active_offset, reactive_offset, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); ESP_LOGI(TAG, "[CALIBRATION][%s] | %c | %6d | %6d |", cs, 'A' + phase, active_offset, reactive_offset); } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - PowerOffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; + OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; this->power_offset_pref_.save(&zero_power_offsets); global_preferences->sync(); + this->has_stored_power_offset_calibration_ = false; this->restored_power_offset_calibration_ = false; for (bool &phase : this->power_offset_calibration_mismatch_) phase = false; @@ -1215,6 +1242,31 @@ bool ATM90E32Component::verify_gain_writes_() { return success; // Return true if all writes were successful, false otherwise } +bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + const char *cs = this->get_calibration_id_(); + const LogString *name = offset_calibration_name(power_offsets); + const LogString *first_name = power_offsets ? LOG_STR("active") : LOG_STR("voltage"); + const LogString *second_name = power_offsets ? LOG_STR("reactive") : LOG_STR("current"); + const OffsetCalibration *offsets = power_offsets ? this->power_offset_phase_ : this->offset_phase_; + const uint16_t *first_registers = power_offsets ? this->power_offset_registers : this->voltage_offset_registers; + const uint16_t *second_registers = + power_offsets ? this->reactive_power_offset_registers : this->current_offset_registers; + bool success = true; + for (uint8_t phase = 0; phase < 3; phase++) { + const uint16_t first = this->read16_(first_registers[phase]); + const uint16_t second = this->read16_(second_registers[phase]); + if (!offset_register_value_matches(first, offsets[phase].first_offset) || + !offset_register_value_matches(second, offsets[phase].second_offset)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s readback failed for Phase %s: %s %d/%d, %s %d/%d.", cs, LOG_STR_ARG(name), + phase_labels[phase], LOG_STR_ARG(first_name), static_cast(first), offsets[phase].first_offset, + LOG_STR_ARG(second_name), static_cast(second), offsets[phase].second_offset); + success = false; + } + } + return success; +} + #ifdef USE_TEXT_SENSOR void ATM90E32Component::check_phase_status() { uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0); diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index c636e5065a5..fe7d903962f 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -13,6 +13,40 @@ namespace esphome::atm90e32 { +inline bool offset_register_value_matches(uint16_t actual, int16_t expected) { + return actual == static_cast(expected); +} + +struct OffsetCalibration { + int16_t first_offset{0}; + int16_t second_offset{0}; +}; + +static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible"); + +enum class OffsetCalibrationType : uint8_t { + OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + OFFSET_CALIBRATION_TYPE_POWER, +}; + +struct OffsetRestoreState { + bool restored; + bool values_verified; +}; + +inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified, + bool fallback_values_verified) { + if (initial_values_verified) + return {has_stored_values, true}; + return {false, fallback_values_verified}; +} + +inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values, + OffsetCalibration (&rollback)[3]) { + for (uint8_t phase = 0; phase < 3; phase++) + rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{}; +} + class ATM90E32Component final : public PollingComponent, public spi::SPIDevice { @@ -71,19 +105,19 @@ class ATM90E32Component final : public PollingComponent, this->has_config_current_gain_[phase] = true; } void set_voltage_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].voltage_offset_ = offset; + this->offset_phase_[phase].first_offset = offset; this->has_config_voltage_offset_[phase] = true; } void set_current_offset(uint8_t phase, int16_t offset) { - this->offset_phase_[phase].current_offset_ = offset; + this->offset_phase_[phase].second_offset = offset; this->has_config_current_offset_[phase] = true; } void set_active_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].active_power_offset = offset; + this->power_offset_phase_[phase].first_offset = offset; this->has_config_active_power_offset_[phase] = true; } void set_reactive_power_offset(uint8_t phase, int16_t offset) { - this->power_offset_phase_[phase].reactive_power_offset = offset; + this->power_offset_phase_[phase].second_offset = offset; this->has_config_reactive_power_offset_[phase] = true; } void set_freq_sensor(sensor::Sensor *freq_sensor) { freq_sensor_ = freq_sensor; } @@ -171,16 +205,16 @@ class ATM90E32Component final : public PollingComponent, float get_chip_temperature_(); bool get_publish_interval_flag_() { return publish_interval_flag_; }; void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; }; - void restore_offset_calibrations_(); - void restore_power_offset_calibrations_(); + void restore_offset_calibrations_(OffsetCalibrationType type); void restore_gain_calibrations_(); - void save_offset_calibration_to_memory_(); void save_gain_calibration_to_memory_(); - void save_power_offset_calibration_to_memory_(); - void write_offsets_to_registers_(uint8_t phase, int16_t voltage_offset, int16_t current_offset); - void write_power_offsets_to_registers_(uint8_t phase, int16_t p_offset, int16_t q_offset); + void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, + bool previous_using_saved, OffsetCalibrationType type); + void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, + OffsetCalibrationType type); void write_gains_to_registers_(); bool verify_gain_writes_(); + bool verify_offset_writes_(OffsetCalibrationType type); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); void log_calibration_status_(); const char *get_calibration_id_(); @@ -219,19 +253,10 @@ class ATM90E32Component final : public PollingComponent, uint32_t cumulative_reverse_active_energy_{0}; } phase_[3]; - struct OffsetCalibration { - int16_t voltage_offset_{0}; - int16_t current_offset_{0}; - } offset_phase_[3]; - + OffsetCalibration offset_phase_[3]; OffsetCalibration config_offset_phase_[3]; - - struct PowerOffsetCalibration { - int16_t active_power_offset{0}; - int16_t reactive_power_offset{0}; - } power_offset_phase_[3]; - - PowerOffsetCalibration config_power_offset_phase_[3]; + OffsetCalibration power_offset_phase_[3]; + OffsetCalibration config_power_offset_phase_[3]; struct GainCalibration { uint16_t voltage_gain{1}; @@ -265,6 +290,8 @@ class ATM90E32Component final : public PollingComponent, bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; const char *instance_id_{nullptr}; + bool has_stored_offset_calibration_{false}; + bool has_stored_power_offset_calibration_{false}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/esphome/components/atm90e32/button/__init__.py b/esphome/components/atm90e32/button/__init__.py index 19f62ccfbd4..274cce6adbb 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/number/__init__.py b/esphome/components/atm90e32/number/__init__.py index 848680b8758..9c2865dde3f 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 dc46138addc..38b24c7cf67 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 ab96f6c2074..30585cb8735 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 d87f32fc362..14a08188949 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,14 +333,13 @@ 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.2.1", - ) + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.0") data = _get_data() @@ -371,7 +374,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 +383,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) diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp index b0aa3c1abbe..402e741059b 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_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index fe9ad9c9add..051395606c8 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -313,9 +313,10 @@ FileDecoderState AudioDecoder::decode_mp3_() { this->output_transfer_buffer_->increase_buffer_length( this->audio_stream_info_.value().frames_to_bytes(samples_decoded)); } - } else if (result == micro_mp3::MP3_STREAM_INFO_READY) { - // First successful header parse: capture stream info and resize the output buffer to fit one full frame. - // microMP3 always outputs 16-bit PCM. + } else if (result == micro_mp3::MP3_STREAM_INFO_READY || result == micro_mp3::MP3_STREAM_INFO_CHANGED) { + // Header parsed: capture stream info and resize the output buffer to fit one full frame. + // microMP3 always outputs 16-bit PCM. MP3_STREAM_INFO_CHANGED is handled identically: despite its + // negative value it is documented as recoverable, so it must not reach the catch-all below. this->audio_stream_info_ = audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate()); this->free_buffer_required_ = diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index 4678ed548c7..e69f33ac2d5 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou if (current_audio_file_ != nullptr) { // A transfer buffer isn't ncessary for a local file this->file_ring_buffer_ = output_ring_buffer.lock(); + if (this->file_ring_buffer_ == nullptr) { + return ESP_ERR_INVALID_STATE; + } return ESP_OK; } diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp index a611549e58d..01fd4bb68a6 100644 --- a/esphome/components/audio/audio_transfer_buffer.cpp +++ b/esphome/components/audio/audio_transfer_buffer.cpp @@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le void AudioTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } } void AudioSinkTransferBuffer::clear_buffered_data() { this->buffer_length_ = 0; - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { this->ring_buffer_->reset(); } #ifdef USE_SPEAKER @@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() { } bool AudioTransferBuffer::has_buffered_data() const { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); @@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_ size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_read = 0; if (bytes_to_read > 0) { - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); } @@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait, bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); } else #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { bytes_written = this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); } else if (this->sink_callback_ != nullptr) { @@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const { return (this->speaker_->has_buffered_data() || (this->available() > 0)); } #endif - if (this->ring_buffer_.use_count() > 0) { + if (this->ring_buffer_ != nullptr) { return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); } return (this->available() > 0); diff --git a/esphome/components/audio_adc/__init__.py b/esphome/components/audio_adc/__init__.py index 3c3a4988b5b..c2bdfb6cb0b 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_dac/__init__.py b/esphome/components/audio_dac/__init__.py index 46c277ce510..1351793afd4 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_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index 04b7d046e6b..fb8620f7d9a 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 f794aa1f027..a97025e53e8 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -33,6 +33,7 @@ class AudioHTTPMediaSource final : public Component, 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; @@ -54,6 +55,7 @@ class AudioHTTPMediaSource final : public Component, // 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 e8acbc81afa..14543957e95 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -7,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 @@ -28,6 +30,7 @@ CONFIG_SCHEMA = cv.All( min=5000, max=1000000 ), 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), @@ -45,3 +48,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_task_stack_in_psram(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 8c18d8ca752..2616cb281bb 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/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index 160d22a5b66..e0ae824b0d0 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 1d5ac6e7023..65540e82fbc 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 final : 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 final : public Component, public esp32_ble_tracker::ESPBTDeviceL }; } // namespace esphome::b_parasite - -#endif // USE_ESP32 diff --git a/esphome/components/b_parasite/sensor.py b/esphome/components/b_parasite/sensor.py index 041303ad8b8..cb8f569c0d6 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/climate.py b/esphome/components/ballu/climate.py index 1127084632d..c4d64cd692d 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/climate.py b/esphome/components/bang_bang/climate.py index bfdb12278fc..65c5eaed187 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 d4bf8138466..1b967e665af 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/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index 4de9dcca0b1..36650d643cc 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/fan/__init__.py b/esphome/components/bedjet/fan/__init__.py index a4a611fefc0..f5dfe32f4cd 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/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index fa9ca7953ea..595e798e490 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/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 9e14615d7a1..0cf970b3cc6 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -300,46 +300,12 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { } 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_}; } @@ -349,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_.value_or(0), 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 909634e266e..1496e65d4d3 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,15 +11,6 @@ namespace esphome::beken_spi_led_strip { -enum RGBOrder : uint8_t { - ORDER_RGB, - ORDER_RBG, - ORDER_GRB, - ORDER_GBR, - ORDER_BGR, - ORDER_BRG, -}; - class BekenSPILEDStripLightOutput final : public light::AddressableLight { public: void setup() override; @@ -28,7 +20,7 @@ class BekenSPILEDStripLightOutput final : 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 final : 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 final : 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 final : 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 9093b08b62d..5576f286f13 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/sensor.py b/esphome/components/bh1750/sensor.py index 36af5aeef94..07272b3b4f0 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/sensor.py b/esphome/components/bh1900nux/sensor.py index a70db3555ad..4ddffb79405 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 dadcf523723..03a03ec7cac 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/light/__init__.py b/esphome/components/binary/light/__init__.py index ebb22f44095..b6eddac3410 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_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 5800e0bd9e4..9ef7efc96a3 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, @@ -451,7 +452,9 @@ _BINARY_SENSOR_SCHEMA = ( cv.Optional( CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED ): validate_device_class, - cv.Optional(CONF_FILTERS): validate_filters, + cv.Optional( + CONF_FILTERS, visibility=cv.Visibility.ADVANCED + ): validate_filters, cv.Optional(CONF_ON_PRESS): automation.validate_automation({}), cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}), cv.Optional(CONF_ON_CLICK): cv.All( @@ -560,6 +563,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] @@ -673,3 +681,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 b13e4a88dd1..65c7dbbdb69 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. @@ -95,13 +100,15 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { } this->is_valid_ = false; this->set_timeout(MULTICLICK_IS_VALID_ID, min_length, [this]() { - ESP_LOGV(TAG, "Multi Click: You can now %s the button.", this->parent_->state ? "RELEASE" : "PRESS"); + ESP_LOGV(TAG, "Multi Click: You can now %s the button.", + this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS")); this->is_valid_ = true; }); } void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { - ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); + ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", + this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS")); this->is_valid_ = false; this->schedule_cooldown_(); }); @@ -120,6 +127,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 +137,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_map/sensor.py b/esphome/components/binary_sensor_map/sensor.py index 965e332e28b..f3133c06211 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 3ffab0f3a5e..e64237c95ab 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_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 29e2a6b13d4..74b9cb59548 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -4,12 +4,15 @@ 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), BK7238/BK7252N/BK7253 -(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time, -not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken -BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only -for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail -with a clear #error. +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; @@ -21,9 +24,16 @@ import logging import esphome.codegen as cg from esphome.components import libretiny -from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238 +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"] @@ -45,7 +55,44 @@ CONFIG_SCHEMA = cv.Schema( ).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) @@ -77,18 +124,7 @@ async def to_code(config: ConfigType) -> None: # 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. - family = libretiny.get_libretiny_family() - if family == FAMILY_BK7231N: + if libretiny.get_libretiny_family() == FAMILY_BK7231N: cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR") - elif family == FAMILY_BK7238: - # ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at - # WiFi STA startup when BLE init runs. This component re-enables BLE, so - # warn loudly: BK7238 is accepted but not hardware-verified and may be - # WiFi-unstable with BLE on. - _LOGGER.warning( - "bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup " - "hang on this family and is not yet hardware-verified. Expect possible " - "instability." - ) 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 00000000000..3192fc79d70 --- /dev/null +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -0,0 +1,120 @@ +// 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 00000000000..47bce2449d5 --- /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 index a5ecaf4abbc..7a4efff4550 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -5,7 +5,8 @@ // talks to the Beken BDK BLE stack: // - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), // - the controller BLE address, -// - the raw controller scan primitives (bk_ble_scan_start/stop), +// - 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 @@ -20,32 +21,39 @@ #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 via its public API, -// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the -// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the -// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header -// itself so any BLE-5.x Beken chip — present or future — is supported without a -// hard-coded list, and a non-5.x build fails here with a clear message instead -// of a cryptic "ble_api.h: No such file or directory". +// 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") +#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 (ble_api.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." + "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 @@ -57,9 +65,8 @@ // are C headers consumed from C++ (a standard C-header-from-C++ pattern). // --------------------------------------------------------------------------- extern "C" { -#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb, - // app_ble_get_idle_actv_idx_handle, struct scan_param, - // recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV +#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(). @@ -76,6 +83,12 @@ 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) @@ -95,21 +108,22 @@ static void ble_notice_callback(ble_notice_t notice, void *param) { 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, info->data, - info->data_len); + 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, const uint8_t *data, - uint16_t 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, 6); + 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); @@ -123,6 +137,9 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add 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_(); @@ -164,8 +181,9 @@ void BK72xxBLE::enable() { break; } } - if (!bdaddr_live) + if (!bdaddr_live) { ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started"); + } #endif this->state_ = BLEComponentState::ACTIVE; @@ -173,25 +191,53 @@ void BK72xxBLE::enable() { } 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) + if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); + } } -void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { +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]; } @@ -224,7 +270,7 @@ void BK72xxBLE::resolve_mac_() { } } if (nonzero) { - memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); return; } #endif @@ -236,54 +282,240 @@ void BK72xxBLE::resolve_mac_() { // (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[6]; + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(wifi_mac); // MSB-first - const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], - wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + 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]; } // --------------------------------------------------------------------------- -// Controller scan primitives +// Scan reconciler // --------------------------------------------------------------------------- -bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) { +// 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(); - if (this->scan_actv_idx_ != 0xFF) { - // Already scanning — stop first so this call cleanly restarts with the new - // parameters (the BDK cannot start a second scan on a busy activity). - this->scan_stop(); + 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_(); +} - struct scan_param sp; - memset(&sp, 0, sizeof(sp)); - sp.channel_map = 7; // advertising channels 37/38/39 - sp.interval = interval; - sp.window = window; +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_(); +} - this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); - if (this->scan_actv_idx_ == 0xFF) { - ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); +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; } - ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr); - if (ret != ERR_SUCCESS) { - ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast(ret)); - this->scan_actv_idx_ = 0xFF; + 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; } -void BK72xxBLE::scan_stop() { - if (this->scan_actv_idx_ != 0xFF) { - bk_ble_scan_stop(this->scan_actv_idx_, nullptr); - this->scan_actv_idx_ = 0xFF; +// 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 diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 2654f4e68e1..687fd396e48 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -6,10 +6,12 @@ #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 + +#include "bdk_scan.h" namespace esphome::bk72xx_ble { @@ -19,11 +21,32 @@ enum class BLEComponentState : uint8_t { 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[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + 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) @@ -60,25 +83,48 @@ class BK72xxBLE final : public Component { 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[6]) const; + 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 - /// Start the controller scan. Interval/window are in BLE units (0.625 ms). - /// Enables the stack first if needed. Returns false on controller failure. - bool scan_start(uint16_t interval, uint16_t window); - /// Stop the controller scan (no-op when not scanning). + /// 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, const uint8_t *data, uint16_t data_len); + 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); - std::vector scan_listeners_; +#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. @@ -87,10 +133,24 @@ class BK72xxBLE final : public Component { // 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_; - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) - uint8_t scan_actv_idx_{0xFF}; - BLEComponentState state_{BLEComponentState::STATE_OFF}; + // 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 diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py new file mode 100644 index 00000000000..96b3536601b --- /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 00000000000..9017d19d71e --- /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 00000000000..0939e1259f6 --- /dev/null +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -0,0 +1,379 @@ +// 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 00000000000..2334cfe414c --- /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/sensor.py b/esphome/components/bl0906/sensor.py index 059e10e962d..1a0c2287ab7 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/sensor.py b/esphome/components/bl0939/sensor.py index bd4bdd93e58..ec17ef2c7ec 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/button/__init__.py b/esphome/components/bl0940/button/__init__.py index 04d11e6e306..e87a647392f 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/number/__init__.py b/esphome/components/bl0940/number/__init__.py index 92ab2837b34..b5a66e682a6 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/sensor.py b/esphome/components/bl0940/sensor.py index 96445d5c38e..7e6403c3bc9 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) @@ -223,7 +226,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/bl0942/sensor.py b/esphome/components/bl0942/sensor.py index f9fe7f5a5e7..5531fe411ba 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 56ac2ea1472..1ef7967fa83 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 94eeb83b3eb..93aae23b6a1 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -22,6 +22,23 @@ class Automation { static const char *const TAG; }; +// Base for nodes that never read the parent's services. +// The parent releases its services only once every node reports Established, so a node that never +// reports it keeps that memory allocated for the life of the connection. +class BLEClientServicelessNode : public BLEClientNode { + public: + // Final so that Established is always reported on SEARCH_CMPL, before the derived node sees the event. + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) final { + if (event == ESP_GATTC_SEARCH_CMPL_EVT) + this->node_state = espbt::ClientState::ESTABLISHED; + this->on_gattc_event(event, gattc_if, param); + } + + protected: + // Derived nodes handle GATT events here rather than by overriding the handler above. + virtual void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {} +}; + // implement on_connect automation. class BLEClientConnectTrigger final : public Trigger<>, public BLEClientNode { public: @@ -61,7 +78,7 @@ class BLEClientDisconnectTrigger final : public Trigger<>, public BLEClientNode } }; -class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientNode { +class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientServicelessNode { public: explicit BLEClientPasskeyRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -71,7 +88,7 @@ class BLEClientPasskeyRequestTrigger final : public Trigger<>, public BLEClientN } }; -class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientNode { +class BLEClientPasskeyNotificationTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientPasskeyNotificationTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -82,7 +99,7 @@ class BLEClientPasskeyNotificationTrigger final : public Trigger, publ } }; -class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientNode { +class BLEClientNumericComparisonRequestTrigger final : public Trigger, public BLEClientServicelessNode { public: explicit BLEClientNumericComparisonRequestTrigger(BLEClient *parent) { parent->register_ble_node(this); } void loop() override {} @@ -315,19 +332,17 @@ template class BLEClientRemoveBondAction final : public Action class BLEClientConnectAction final : public Action, public BLEClientNode { +template class BLEClientConnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientConnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { case ESP_GATTC_SEARCH_CMPL_EVT: - this->node_state = espbt::ClientState::ESTABLISHED; this->parent()->run_later([this]() { this->play_next_tuple_(this->var_); }); break; // if the connection is closed, terminate the automation chain. @@ -364,14 +379,13 @@ template class BLEClientConnectAction final : public Action var_{}; }; -template class BLEClientDisconnectAction final : public Action, public BLEClientNode { +template class BLEClientDisconnectAction final : public Action, public BLEClientServicelessNode { public: BLEClientDisconnectAction(BLEClient *ble_client) { ble_client->register_ble_node(this); ble_client_ = ble_client; } - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override { + void on_gattc_event(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { if (this->num_running_ == 0) return; switch (event) { diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index d41fb17961b..25001c8f74b 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 f27bef332b6..f20df318162 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_; } diff --git a/esphome/components/ble_client/output/ble_binary_output.cpp b/esphome/components/ble_client/output/ble_binary_output.cpp index 1cb83b9d8b3..5d53c597086 100644 --- a/esphome/components/ble_client/output/ble_binary_output.cpp +++ b/esphome/components/ble_client/output/ble_binary_output.cpp @@ -80,8 +80,9 @@ void BLEBinaryOutput::write_state(bool state) { esp_err_t err = esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, sizeof(state_as_uint), &state_as_uint, this->write_type_, ESP_GATT_AUTH_REQ_NONE); - if (err != ESP_GATT_OK) + if (err != ESP_GATT_OK) { ESP_LOGW(TAG, "[%s] Write error, err=%d", this->char_uuid_.to_str(char_buf), err); + } } } // namespace esphome::ble_client diff --git a/esphome/components/ble_client/sensor/__init__.py b/esphome/components/ble_client/sensor/__init__.py index 0975640ece6..7764955d891 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/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 4bd871dc817..5dbb7e42ed2 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/text_sensor/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index 0f53cccdad0..820f60845d8 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/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index 7eaa6af0761..ed2b0a63a0e 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 index d9789b0e9f2..43ec7367277 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -3,57 +3,149 @@ 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) on every platform. +(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 platform table here and no dependency in either -direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and -calls register_ble_device() in to_code; a tracker component subclasses BLEHub -(C++ and codegen class). Adding a new BLE chip requires only a new tracker -component. +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.core import CORE +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" -# CORE.data key: number of parsed-advertisement listeners registered in this -# build. Trackers whose codegen sizes storage at compile time (esp32's -# StaticVector count define) read it in their final coroutine. -KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count" +# 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. +# 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") -def inject_ble_hub(config: ConfigType) -> ConfigType: - """Validator: auto-resolve the configured BLE tracker into the config. +# 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() - Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID + - cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on - any platform; multiple trackers can be disambiguated with an explicit - ble_hub_id. - """ - return cv.Schema( - {cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA - )(config) +# 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: @@ -62,20 +154,138 @@ def request_irk_support() -> None: cg.add_define("USE_BLE_DEVICE_IRK") -def get_listener_count() -> int: - """Number of parsed listeners registered so far (for tracker codegen).""" - return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) +# 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)) - CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1 + _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" @@ -132,3 +342,25 @@ def as_hex_array(value: str) -> cg.RawExpression: 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 00000000000..ba3128c0eed --- /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 00000000000..6acc4edb924 --- /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_client_state.cpp b/esphome/components/ble_device_base/ble_client_state.cpp new file mode 100644 index 00000000000..55817024b52 --- /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 00000000000..92754b70b48 --- /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 index c025af4d284..23ca6b1dbd8 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -8,6 +8,7 @@ #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" @@ -98,6 +99,8 @@ ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) { #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) @@ -108,6 +111,10 @@ ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { 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; @@ -129,8 +136,8 @@ 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() then produce exactly the historical esp32 values. - uint8_t mac_lsb_first[6]; + // 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, @@ -139,7 +146,8 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { #endif // USE_ESP32 ESPBTUUID ESPBTUUID::as_128bit() const { - if (this->type_ == Type::UUID128) + // 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); @@ -149,6 +157,8 @@ ESPBTUUID ESPBTUUID::as_128bit() const { 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: @@ -173,6 +183,9 @@ 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'; @@ -207,6 +220,7 @@ const char *ESPBTUUID::to_str(char *buf) const { 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) { @@ -223,6 +237,8 @@ void ESPBTUUID::to_128bit_(uint8_t out[16]) const { 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: @@ -232,6 +248,9 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const { } 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]; @@ -247,16 +266,21 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const { ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); } -optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData &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 - // Parity with esp32_ble_tracker: gate on the Apple company ID and length only. - // (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior - // change; it belongs to a follow-up, not this refactor.) 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()); } @@ -264,6 +288,59 @@ optional ESPBLEiBeacon::from_manufacturer_data(const ServiceData // 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) @@ -272,7 +349,8 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty this->address_[i] = mac[5 - i]; this->address_type_ = addr_type; this->rssi_ = rssi; - this->name_.clear(); + this->name_len_ = 0; + this->name_[0] = '\0'; this->service_uuids_.clear(); this->manufacturer_datas_.clear(); this->service_datas_.clear(); @@ -282,29 +360,13 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty this->parse_adv_(data, data_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; - } char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; - ESP_LOGVV(TAG, " Address: %s (%s)", this->address_str_to(addr_buf), address_type); - ESP_LOGVV(TAG, " RSSI: %d", this->rssi_); - ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str()); + 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); } @@ -322,25 +384,32 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty for (auto &mfg_data : this->manufacturer_datas_) { auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data); if (ibeacon.has_value()) { - ESP_LOGVV(TAG, " Manufacturer iBeacon:"); - ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_str(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()); + 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:"); - ESP_LOGVV(TAG, " UUID: %s", svc_data.uuid.to_str(uuid_buf)); - ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size())); + 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)); @@ -409,8 +478,12 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { // 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_.length()) - this->name_.assign(reinterpret_cast(ad_data), ad_data_len); + 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 @@ -493,4 +566,33 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { } } +// --------------------------------------------------------------------------- +// 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 index 6b52a8f842c..668f7e09f8a 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -14,6 +14,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include #include @@ -85,14 +86,17 @@ class ESPBTUUID { bool operator==(const ESPBTUUID &other) const; bool operator!=(const ESPBTUUID &other) const { return !(*this == other); } - /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf - /// (>= UUID_STR_LEN bytes) and return buf. + /// 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 - enum class Type : uint8_t { UUID16, UUID32, UUID128 }; + // 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; } @@ -101,7 +105,7 @@ class ESPBTUUID { // 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::UUID16}; + Type type_{Type::UNSET}; union { uint16_t uuid16; uint32_t uuid32; @@ -126,7 +130,12 @@ class ESPBLEiBeacon { public: ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } explicit ESPBLEiBeacon(const uint8_t *data); - static optional from_manufacturer_data(const ServiceData &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); } @@ -144,6 +153,24 @@ class ESPBLEiBeacon { } 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 // --------------------------------------------------------------------------- @@ -158,8 +185,9 @@ class ESPBTDevice { 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; - /// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf. + /// 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 { @@ -175,15 +203,22 @@ class ESPBTDevice { // 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_; } - const std::string &get_name() const { return name_; } + /// 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_; } @@ -197,22 +232,22 @@ class ESPBTDevice { /// decryptor; compiled only when a sensor configures irk: (request_irk_support). bool resolve_irk(const uint8_t *irk) const; - optional get_ibeacon() const { - for (const auto &it : this->manufacturer_datas_) { - auto res = ESPBLEiBeacon::from_manufacturer_data(it); - if (res.has_value()) - return res; - } - return {}; - } + optional get_ibeacon() const; protected: void parse_adv_(const uint8_t *payload, uint16_t len); - uint8_t address_[6]{0}; + // 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}; - std::string name_{}; + // 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_{}; @@ -224,6 +259,24 @@ class ESPBTDevice { 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) // --------------------------------------------------------------------------- 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 00000000000..b95fb6878af --- /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 index 0f3f4de88d7..9da63710123 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -1,13 +1,10 @@ // ble_hub.h // -// BLEHub — the platform-neutral BLE tracker contract. -// -// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, -// ln882h_ble_tracker, future chips) implements this interface; every BLE -// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via -// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares. -// Adding a new BLE chip therefore requires only a new tracker component that -// implements BLEHub: no consumer, registry, or base changes. +// 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. @@ -15,17 +12,62 @@ #pragma once #include "ble_device.h" +#include "esphome/core/defines.h" +#include #include -#include namespace esphome::ble_device_base { -/// Callback for raw advertisements (the bluetooth_proxy path). -/// mac[] is least-significant octet first (BLE controller convention); -/// the hub delivers on the ESPHome main loop. -using RawAdvertisementCallback = - std::function; +/// 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 { @@ -35,29 +77,46 @@ struct HubCapabilities { /// 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 (today: esp32 only, but a chip SDK - /// gaining GATT support only has to flip this bit). + /// 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; }; -class BLEHub { - public: - virtual ~BLEHub() = default; - - /// Register a parsed-advertisement consumer (BLE sensors, automation triggers). - virtual void register_listener(ESPBTDeviceListener *listener) = 0; - - /// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. - virtual void set_raw_advertisement_callback(RawAdvertisementCallback cb) = 0; - - virtual HubCapabilities get_capabilities() const = 0; - - /// Adapter MAC in printable (MSB-first) order, out[0] = MSB. - virtual void get_adapter_mac(uint8_t out[6]) = 0; - - virtual bool scan_running() = 0; - /// True when the current/configured scan mode is active (scan requests sent). - virtual bool scan_active() = 0; +// 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 00000000000..87214ca7f72 --- /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 00000000000..2c0d766683f --- /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 00000000000..f28790f2079 --- /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_presence/binary_sensor.py b/esphome/components/ble_presence/binary_sensor.py index 3a0f1ade985..a43d3561f8e 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 4a70648ac52..bc169623ce0 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 e17e26ff1c4..4e49cc32a3b 100644 --- a/esphome/components/ble_presence/ble_presence_device.h +++ b/esphome/components/ble_presence/ble_presence_device.h @@ -1,15 +1,17 @@ #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 final : public binary_sensor::BinarySensorInitiallyOff, - public esp32_ble_tracker::ESPBTDeviceListener, + public ble_device_base::ESPBTDeviceListener, public Component { public: void set_address(uint64_t address) { @@ -22,19 +24,19 @@ class BLEPresenceDevice final : 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 final : 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 final : 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 final : 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 f678865f47c..7c7c7b21487 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 8e804ab8e70..a30b94b8b7c 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 final : 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 final : public sensor::Sensor, public esp32_ble_tracker::ESP } 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 final : public sensor::Sensor, public esp32_ble_tracker::ESP 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 final : public sensor::Sensor, public esp32_ble_tracker::ESP 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 final : public sensor::Sensor, public esp32_ble_tracker::ESP }; } // namespace esphome::ble_rssi - -#endif diff --git a/esphome/components/ble_rssi/sensor.py b/esphome/components/ble_rssi/sensor.py index c4e767aa21a..6813505d587 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 d85894edc8a..3d7a3017931 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 c70ee637ef6..0efc42682b2 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -5,35 +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 final : 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\"}", @@ -45,5 +35,3 @@ class BLEScanner final : public text_sensor::TextSensor, }; } // namespace esphome::ble_scanner - -#endif diff --git a/esphome/components/ble_scanner/text_sensor.py b/esphome/components/ble_scanner/text_sensor.py index 96d71a0399d..0c60b53783b 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 00000000000..fa1a86be3a3 --- /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 00000000000..a001729083c --- /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 00000000000..b21d997b4fc --- /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 00000000000..986a67c7a8f --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,785 @@ +#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()) { + // ble_before_disabled_event_handler() settles the slot. + 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; connect() rejects until it lands. + 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 (pre-started search flush); it + // settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer +// frees its slot, then register the app again on the next enable. +void BluedroidGattClient::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != ClientState::IDLE && st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(ClientState::INIT); + // An idle slot runs no loop; the INIT branch must run to register again. + this->enable_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) { + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + ESP_LOGW(TAG, "[%d] Connect rejected, GATT app not registered", this->connection_index_); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // 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 00000000000..f285260e763 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,143 @@ +// 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; + void ble_before_disabled_event_handler() 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 00000000000..3c982d81aec --- /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 00000000000..ddab4812cce --- /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 00000000000..47181e81a73 --- /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 00000000000..16a89dcfdd1 --- /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 00000000000..4d407269b60 --- /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 ad7528c1567..c87ad7f5957 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,46 @@ 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) + + +def enable_advertisement_filter() -> None: + """Compile the advertisement filter hook into bluetooth_proxy. + + Called by external filtering components from to_code(). The define behind + this is an implementation detail; do not emit it directly. + + Public API for external components. Do not remove. + """ + cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER") + + +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 +418,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 9820977a134..00000000000 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ /dev/null @@ -1,597 +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) - // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the - // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. - if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && - (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { - this->disable_loop(); - } -} - -void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { - // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the - // base class. Free the proxy slot, notify the API client, and reset send_service_. - // address_ may already be 0 if reset_connection_ ran earlier on this teardown. - if (this->address_ == 0) { - return; - } - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); - this->reset_connection_(reason); -} - -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; - // Bound by total_char_count: the vector is sized for it, and a malicious peripheral - // can make enumeration return more entries than the count query reported - while (char_offset < total_char_count) { // 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 (desc_offset < total_desc_count) { // 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_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 e5600f6af49..00000000000 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.h +++ /dev/null @@ -1,62 +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; - - void on_disconnect_complete(esp_err_t reason) override; - - 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 37ebcad8b48..cb37057cd4b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -1,15 +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"; @@ -23,41 +25,125 @@ 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; + +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + // Ask the filter before the packet is queued, so a dropped advertisement never + // reaches the batch or the network. + if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) { + ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address); + return; + } +#endif + + 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); @@ -66,104 +152,179 @@ 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 +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set())); +#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) { @@ -173,99 +334,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; } } @@ -279,8 +438,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); } } @@ -292,8 +451,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); } } @@ -305,8 +464,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); } } @@ -318,8 +477,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); } } @@ -329,9 +488,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 @@ -346,14 +528,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; @@ -361,10 +545,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; } @@ -375,26 +561,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 && this->api_connection_ != api_connection) { - // A previous subscriber still holds the slot. This is almost always a stale - // connection from a client that dropped without a clean disconnect and has - // not yet hit the keepalive timeout; rejecting the new subscriber would - // silently starve it of advertisements until it reconnects, so the newest - // subscriber wins instead. - char old_peername[socket::SOCKADDR_STR_LEN]; - char new_peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), - api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), - this->api_connection_->get_peername_to(old_peername)); + 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) { @@ -403,19 +770,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_); @@ -423,28 +781,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; @@ -452,33 +827,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 2b6d29da433..567109dc60b 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,32 +60,90 @@ 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 + +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER +/// Predicate slot letting an external component drop advertisements before they +/// are queued for the API. Same shape as +/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot +/// path, so it must be cheap and must not block. +/// +/// Usage: +/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { +/// return static_cast(self)->should_forward(adv); +/// }}); +/// +/// Returning false drops the advertisement. Not called at all while the API is +/// disconnected, which matters to a stateful filter. Compiled in only when an +/// external component calls bluetooth_proxy.enable_advertisement_filter(). +struct AdvertisementFilter { + void *instance{nullptr}; + bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no filter"; the proxy guards on this. + bool is_set() const { return this->fn != nullptr; } + bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); } +}; +#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + +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; - // maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused. - void register_connection([[maybe_unused]] BluetoothConnection *connection) { - // Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 - if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) { - this->connections_[this->connection_count_++] = connection; - connection->proxy_ = this; - } -#endif - } +#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); @@ -87,63 +152,97 @@ 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; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + /// One subscriber; a later call replaces an earlier one. + void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; } +#endif 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'; @@ -151,51 +250,153 @@ 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}; + +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + AdvertisementFilter advertisement_filter_{}; +#endif // 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/time.py b/esphome/components/bm8563/time.py index ba264f00bfe..5ef162bb7c4 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 c37191bc078..287946801eb 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/sensor.py b/esphome/components/bme280_i2c/sensor.py index 1c37033613c..536e8ec7941 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/sensor.py b/esphome/components/bme280_spi/sensor.py index 7f4fb5cf44b..1d53fe25fa3 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.cpp b/esphome/components/bme680/bme680.cpp index ef98174e064..164424de096 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -327,10 +327,12 @@ void BME680Component::read_data_() { ESP_LOGD(TAG, "Got temperature=%.1f°C pressure=%.1fhPa humidity=%.1f%% gas_resistance=%.1fΩ", temperature, pressure, humidity, gas_resistance); - if (!gas_valid) + if (!gas_valid) { ESP_LOGW(TAG, "Gas measurement unsuccessful, reading invalid!"); - if (!heat_stable) + } + if (!heat_stable) { ESP_LOGW(TAG, "Heater unstable, reading invalid! (Normal for a few readings after a power cycle)"); + } if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index f41aefcec3f..dce5c88cfaa 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 e1e01facd01..35df2a7ea3f 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -3,6 +3,7 @@ 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"] @@ -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 823f32c446a..8e16a28e336 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -162,8 +162,9 @@ void BME680BSECComponent::dump_config() { " Supply Voltage: %sV\n" " Sample Rate: %s\n" " 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", + this->temperature_offset_, + this->iaq_mode_ == IAQ_MODE_STATIC ? LOG_STR_LITERAL("Static") : LOG_STR_LITERAL("Mobile"), + this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? LOG_STR_LITERAL("3.3") : LOG_STR_LITERAL("1.8"), BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); diff --git a/esphome/components/bme680_bsec/sensor.py b/esphome/components/bme680_bsec/sensor.py index bdc8d8f2d38..153890b57f1 100644 --- a/esphome/components/bme680_bsec/sensor.py +++ b/esphome/components/bme680_bsec/sensor.py @@ -29,6 +29,8 @@ 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 @@ -110,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)) @@ -120,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 1fbb9e2aeb8..6da1c9d287b 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 63f63c5da2e..8208672b6a5 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,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"] @@ -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) diff --git a/esphome/components/bme68x_bsec2/sensor.py b/esphome/components/bme68x_bsec2/sensor.py index 52587dba99e..863cd9d601c 100644 --- a/esphome/components/bme68x_bsec2/sensor.py +++ b/esphome/components/bme68x_bsec2/sensor.py @@ -29,6 +29,8 @@ 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 @@ -119,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)) @@ -127,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 fce00afe343..5c6f9f696c7 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 c8ca0ba022c..1da3bdbd6c6 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/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index cc4037c1ee7..4309f0a79f1 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/motion.py b/esphome/components/bmi270/motion.py index c1616665f9c..ad36e592d14 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -8,6 +8,7 @@ from esphome.components.const import ( ) 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 @@ -79,7 +80,7 @@ CONFIG_SCHEMA = ( # Code generation -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await new_motion_component(config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py index 69235ed8dc2..0e1b0604a3d 100644 --- a/esphome/components/bmi270/sensor.py +++ b/esphome/components/bmi270/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, ) from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_BMI270_ID, BMI270Component @@ -30,7 +31,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +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") diff --git a/esphome/components/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index 6e51984e1fb..e4e559844e1 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 d612920dd49..c0f0ae90bf9 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/sensor.py b/esphome/components/bmp280_i2c/sensor.py index 3ff556d51a5..8e3c14f50a5 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/sensor.py b/esphome/components/bmp280_spi/sensor.py index b3678ec01d9..d97a6ea5797 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 c31db31761b..75e168378e1 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/sensor.py b/esphome/components/bmp3xx_i2c/sensor.py index 6fed9fc9ee2..46e50ea39f9 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/sensor.py b/esphome/components/bmp3xx_spi/sensor.py index 22aab71977f..fb3580bbad4 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 6a7cf450894..1c2c5c37d42 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/sensor.py b/esphome/components/bmp581_i2c/sensor.py index 42645022a62..b4cd00325d3 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/sensor.py b/esphome/components/bmp581_spi/sensor.py index db0d0cd529c..435c5cd6f9f 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 dc80c67b44c..b45272d2eaf 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/output.py b/esphome/components/bp1658cj/output.py index 78cf717aba2..93e3c75daf9 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 af78b38ef50..fa4e8a231bb 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/output.py b/esphome/components/bp5758d/output.py index 9adf13de55b..bbca7c18cc5 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/bridge/__init__.py b/esphome/components/bridge/__init__.py new file mode 100644 index 00000000000..49811b01819 --- /dev/null +++ b/esphome/components/bridge/__init__.py @@ -0,0 +1,4 @@ +CODEOWNERS = ["@kbx81"] +DOMAIN = "bridge" + +IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index 8ce216da221..ed0cbaa9e14 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 ff38ab1740f..1ebabea0a33 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; @@ -232,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; } @@ -435,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 924858e4496..4a953115573 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 final : 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 final : public esp32_ble_tracker::ESPBTDeviceListener, 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 final : public esp32_ble_tracker::ESPBTDeviceListener, }; } // namespace esphome::bthome_mithermometer - -#endif diff --git a/esphome/components/bthome_mithermometer/sensor.py b/esphome/components/bthome_mithermometer/sensor.py index 9b50866db0f..f559d0aa9b1 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 a4245f43e61..ee24002b8a3 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 @@ -88,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) @@ -101,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) @@ -109,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 @@ -125,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/camera/camera.h b/esphome/components/camera/camera.h index bf80b42e54d..433361d2985 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/canbus/__init__.py b/esphome/components/canbus/__init__.py index fcd342ad389..b7de235dd18 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/cap1188/__init__.py b/esphome/components/cap1188/__init__.py index cde9dd46aec..eff0a051639 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 b7af53638a6..21fd98ed410 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/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 703ae983926..e490d89062a 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 @@ -61,7 +61,7 @@ CONFIG_SCHEMA = cv.All( ) -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,19 +88,20 @@ 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") diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index a81edc19005..84d79a6c664 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 365e5f64db5..599422a46b0 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/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index d9023a415f9..d134d2cf21f 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 af6866df789..5f7778e1861 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/sensor.py b/esphome/components/cd74hc4067/sensor.py index dceaf6f3715..670b0502715 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/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py new file mode 100644 index 00000000000..516af848564 --- /dev/null +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@kbx81"] diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py new file mode 100644 index 00000000000..cee048df5da --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -0,0 +1,114 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, uart, usb_cdc_acm +from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"] + +CONF_DTR_PIN = "dtr_pin" +CONF_RTS_PIN = "rts_pin" +CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id" + +cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart") +CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CDCACMUARTBridge), + cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent), + cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance), + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + } + ).extend(cv.COMPONENT_SCHEMA), + # Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on + # hardware; extend once verified. + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + """Return True if any dict in the subtree has a uart_id entry naming this bus.""" + if isinstance(node, dict): + return any( + (key == CONF_UART_ID and str(value) == uart_id) + or _subtree_references_uart(value, uart_id) + for key, value in node.items() + ) + if isinstance(node, list): + return any(_subtree_references_uart(item, uart_id) for item in node) + return False + + +def _reject_debug(uart_conf: ConfigType) -> ConfigType: + # The worker tasks use the IDF driver directly, so the uart debugger never sees + # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. + if CONF_DEBUG in uart_conf: + raise cv.Invalid( + "A bridged UART cannot use 'debug'; the bridge bypasses the UART " + "component's read/write path.", + [CONF_DEBUG], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + # Bridges of any platform must own their interfaces exclusively; shared ring + # buffers and overwritten callbacks would corrupt both streams silently. The + # seen-set is keyed on the bridge domain so future platforms share it. + # Other components bind either interface through the same uart_id key (the CDC + # instance is itself a uart::UARTComponent) and would race the worker tasks. + # Bare `id:` references (a uart.write action) cannot be distinguished; not caught. + data = full_config.data.setdefault(BRIDGE_DOMAIN, {}) + for conf_key, label in ( + (CONF_UART_ID, "UART"), + (CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"), + ): + owned_id = str(config[conf_key]) + used = data.setdefault(conf_key, set()) + if owned_id in used: + raise cv.Invalid( + f"The {label} '{owned_id}' is already bridged by another 'bridge' " + f"instance; each bridge requires its own {label}.", + [conf_key], + ) + used.add(owned_id) + for domain, domain_conf in full_config.items(): + if domain == BRIDGE_DOMAIN: + continue + if _subtree_references_uart(domain_conf, owned_id): + raise cv.Invalid( + f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " + f"requires exclusive use of its {label}.", + [conf_key], + ) + + fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID]) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + uart_component = await cg.get_variable(config[CONF_UART_ID]) + usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID]) + var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) + await cg.register_component(var, config) + + if dtr_pin_config := config.get(CONF_DTR_PIN): + dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) + cg.add(var.set_dtr_pin(dtr_pin)) + if rts_pin_config := config.get(CONF_RTS_PIN): + rts_pin = await cg.gpio_pin_expression(rts_pin_config) + cg.add(var.set_rts_pin(rts_pin)) diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp new file mode 100644 index 00000000000..042688bfe61 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -0,0 +1,468 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "cdc_acm_uart_bridge.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "driver/uart.h" +#include "soc/soc_caps.h" + +namespace esphome::cdc_acm_uart { + +static const char *const TAG = "cdc_acm_uart"; + +static constexpr size_t UART_TASK_STACK_SIZE = 4096; +static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20; +// Above the default priority but below the USB/Wi-Fi system tasks. +static constexpr UBaseType_t TASK_PRIORITY = 4; + +static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) { + uint32_t now = millis(); + if ((now - *last_ms) >= interval_ms) { + *last_ms = now; + return true; + } + return false; +} + +static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) { + if (len == 0) { + return true; + } + + if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) { + return true; + } + + size_t offset = 0; + while (offset < len) { + size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset); + if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) { + if (should_log_now(log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "USB TX buffer full; some data is lost"); + } + return false; + } + offset += chunk; + } + return true; +} + +void CDCACMUARTBridge::setup() { + // Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is + // handled by configuring the pins inverted, so deasserted idles HIGH. + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(false); + } + + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(false); + } + + // A failed UART never assigned its port number, so the worker tasks would run + // against an indeterminate port. + if (this->uart_parent_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->configured_baud_rate_ = this->uart_parent_->get_baud_rate(); + this->configured_parity_ = this->uart_parent_->get_parity(); + this->configured_stop_bits_ = this->uart_parent_->get_stop_bits(); + this->configured_data_bits_ = this->uart_parent_->get_data_bits(); + + // usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks + // the hub failed, and a failed hub no longer runs loop(), so line coding and line + // state events would never reach this bridge even if its own interface is healthy. + if (this->usb_cdc_parent_->get_parent()->is_failed()) { + ESP_LOGE(TAG, "USB CDC ACM failed; aborting"); + this->mark_failed(); + return; + } + + // Per-instance task names (keyed on the CDC interface number) keep task dumps + // unambiguous with multiple bridges. + char tx_task_name[] = "cdc_uart_tx_0"; + char rx_task_name[] = "cdc_uart_rx_0"; + const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf()); + tx_task_name[sizeof(tx_task_name) - 2] = itf_char; + rx_task_name[sizeof(rx_task_name) - 2] = itf_char; + + xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_); + if (this->uart_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART TX task"); + this->mark_failed(); + return; + } + + xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_); + if (this->uart_rx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART RX task"); + vTaskDelete(this->uart_tx_task_handle_); + this->uart_tx_task_handle_ = nullptr; + this->mark_failed(); + return; + } + + // Only register callbacks once both tasks exist, so a failed setup never drives + // DTR/RTS from a dead bridge. + this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); }); + this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) { + this->host_coding_seen_ = true; + // Another component owns the UART's framing while paused; resume() re-syncs. + if (this->paused_ == 0) { + this->set_line_coding(); + } + }); + + // Release the workers only now: until here a failed setup may still delete the TX + // task, which is safe only while it is parked and owns nothing in the driver. + xTaskNotifyGive(this->uart_tx_task_handle_); + xTaskNotifyGive(this->uart_rx_task_handle_); + + // loop() only services line-coding reloads; stay off the main loop until one is + // scheduled. + this->disable_loop(); +} + +void CDCACMUARTBridge::dump_config() { + ESP_LOGCONFIG(TAG, + "CDC-ACM UART Bridge:\n" + " UART Bus: %u\n" + " USB CDC Interface: %u", + this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf()); + LOG_PIN(" DTR Pin: ", this->dtr_pin_); + LOG_PIN(" RTS Pin: ", this->rts_pin_); +} + +void CDCACMUARTBridge::on_shutdown() { + // The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver, + // freeing the ring buffer and mutexes the worker tasks block on. Suspending the + // tasks unlinks them from those objects first. + if (this->uart_rx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_rx_task_handle_); + } + if (this->uart_tx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_tx_task_handle_); + } +} + +void CDCACMUARTBridge::loop() { + switch (this->state_) { + case MainState::MAIN_STATE_RELOAD_PENDING: + if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) { + return; + } + // Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream + // wants the new framing now, and its own in-flight bytes are its concern. + // apply_settings_live() rewrites the framing registers without reinstalling the + // driver, so the worker tasks blocked inside it are undisturbed. + this->uart_parent_->apply_settings_live(); + this->state_ = MainState::MAIN_STATE_RUNNING; + break; + case MainState::MAIN_STATE_PAUSING: + case MainState::MAIN_STATE_RESUMING: + // Let a host write that was in flight drain, FIFO included, before a reload + // flushes the FIFOs and truncates it. + if (!this->tx_idle_()) { + return; + } + if (this->state_ == MainState::MAIN_STATE_PAUSING) { + this->restore_configured_framing_(); + this->state_ = MainState::MAIN_STATE_PAUSED; + } else { + this->finish_resume_(); + } + break; + default: + break; + } + this->disable_loop(); +} + +void CDCACMUARTBridge::set_line_coding() { + if (!this->sync_host_framing_()) { + return; + } + // Coalesce rapid line-coding updates from the host. + this->reload_requested_at_ = App.get_loop_component_start_time(); + this->state_ = MainState::MAIN_STATE_RELOAD_PENDING; + // Main-loop context (via USBCDCACMInstance::process_events_). + this->enable_loop(); +} + +bool CDCACMUARTBridge::sync_host_framing_() { + // usb_cdc_acm has already translated the wire coding onto the CDC instance (main + // loop); mirror it here so the framing translation has a single source of truth. + bool changed = false; + + // Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate) + // and rates above the SoC ceiling. Anything in between is the driver's call, + // matching what a YAML-configured UART accepts. + const uint32_t baud = this->usb_cdc_parent_->get_baud_rate(); + if (baud == 0 || baud > SOC_UART_BITRATE_MAX) { + ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud, + this->uart_parent_->get_baud_rate()); + } else if (this->uart_parent_->get_baud_rate() != baud) { + this->uart_parent_->set_baud_rate(baud); + changed = true; + } + + const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits(); + if (this->uart_parent_->get_stop_bits() != stop_bits) { + this->uart_parent_->set_stop_bits(stop_bits); + changed = true; + } + + const auto parity = this->usb_cdc_parent_->get_parity(); + if (this->uart_parent_->get_parity() != parity) { + this->uart_parent_->set_parity(parity); + changed = true; + } + + // USB CDC permits data-bit counts the UART cannot represent (up to 16). + const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits(); + if (data_bits < 5 || data_bits > 8) { + ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits, + this->uart_parent_->get_data_bits()); + } else if (this->uart_parent_->get_data_bits() != data_bits) { + this->uart_parent_->set_data_bits(data_bits); + changed = true; + } + + if (changed) { + ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u", + this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(), + this->uart_parent_->get_stop_bits(), static_cast(this->uart_parent_->get_parity())); + } + return changed; +} + +void CDCACMUARTBridge::pause() { + if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) { + return; + } + this->paused_ = 1; + // A null RX task means setup() has not completed (or failed): nothing to stop, and + // the framing snapshot does not exist yet. Should setup() run later, the RX task + // starts parked. + if (this->uart_rx_task_handle_ == nullptr) { + this->state_ = MainState::MAIN_STATE_PAUSED; + return; + } + // Drops a coalesced host reload or a pending resume; loop() restores the framing + // once any host write in flight has drained. + this->state_ = MainState::MAIN_STATE_PAUSING; + this->enable_loop(); +} + +void CDCACMUARTBridge::resume() { + if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) { + return; + } + if (this->uart_rx_task_handle_ == nullptr) { + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + return; + } + // A restore still waiting on the TX side is moot: the host's framing is kept. + if (!this->tx_idle_()) { + this->state_ = MainState::MAIN_STATE_RESUMING; + this->enable_loop(); + return; + } + this->finish_resume_(); + this->disable_loop(); +} + +void CDCACMUARTBridge::finish_resume_() { + // Take the bus back at a known framing before either task runs again: the host's + // if it ever sent one, else the YAML framing (the other owner may have changed it). + if (this->host_coding_seen_) { + this->sync_host_framing_(); + this->uart_parent_->apply_settings_live(); + } else { + this->restore_configured_framing_(); + } + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + this->drive_line_state_(); + xTaskNotifyGive(this->uart_rx_task_handle_); +} + +bool CDCACMUARTBridge::tx_idle_() { + const auto uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK; +} + +void CDCACMUARTBridge::restore_configured_framing_() { + // Always applied: the cached settings can lead the hardware by a pending reload, + // so they are no proof of what is live. + this->uart_parent_->set_baud_rate(this->configured_baud_rate_); + this->uart_parent_->set_parity(this->configured_parity_); + this->uart_parent_->set_stop_bits(this->configured_stop_bits_); + this->uart_parent_->set_data_bits(this->configured_data_bits_); + this->uart_parent_->apply_settings_live(); +} + +void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) { + ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts); + this->host_dtr_ = dtr; + this->host_rts_ = rts; + // Frozen while paused: a host opening the port must not reset a peer that another + // component is talking to. + if (this->paused_ == 0) { + this->drive_line_state_(); + } +} + +void CDCACMUARTBridge::drive_line_state_() { + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->digital_write(this->host_dtr_); + } + if (this->rts_pin_ != nullptr) { + this->rts_pin_->digital_write(this->host_rts_); + } +} + +void CDCACMUARTBridge::uart_rx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_rx_task_(); +} + +void CDCACMUARTBridge::uart_tx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_tx_task_(); +} + +void CDCACMUARTBridge::uart_rx_task_() { + TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle(); + RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + + uint8_t *data = this->uart_rx_buffer_.data(); + const size_t buf_size = this->uart_rx_buffer_.size(); + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + if (this->paused_ != 0) { + // Parked until resume() notifies; nothing is read, so the other owner sees + // every byte. + this->rx_parked_ = 1; + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + this->rx_parked_ = 0; + continue; + } + + // Block until at least one byte is available from UART. + int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS)); + if (total_rx_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", total_rx_size); + } + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + if (total_rx_size == 0) { + continue; + } + // pause() landed during the read: don't forward a byte to a host that is gone. + if (this->paused_ != 0) { + continue; + } + + // Drain the currently buffered burst without waiting. + while (true) { + int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0); + if (rx_data_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", rx_data_size); + } + break; + } + if (rx_data_size == 0) { + break; + } + ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size); + total_rx_size += rx_data_size; + if (total_rx_size >= (int) buf_size) { + break; + } + } + + ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms); + + ESP_LOGV(TAG, "UART RX: waking up USB TX task"); + xTaskNotifyGive(usb_tx_handle); + } +} + +void CDCACMUARTBridge::uart_tx_task_() { + RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + uint8_t *data_to_uart = this->uart_tx_buffer_.data(); + const size_t buf_size = this->uart_tx_buffer_.size(); + size_t rx_size; + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS; + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + ESP_LOGV(TAG, "Waiting for data to send to UART"); + esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY); + + if (ret != ESP_OK) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "USB RX RingBuf read failed"); + } + // Yield: this task runs above the main loop, so a persistent failure must not + // become a tight loop. + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + // Another component owns the UART; host bytes must not interleave with its traffic. + // tx_busy_ goes up before the check so is_paused() cannot miss a write in flight. + this->tx_busy_ = 1; + if (this->paused_ != 0) { + this->tx_busy_ = 0; + if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size); + } + continue; + } + + ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size); + // Signed: uart_write_bytes() returns -1 on error. + int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size); + this->tx_busy_ = 0; + + if (xfer_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART write failed: %d", xfer_size); + } + } else if (static_cast(xfer_size) != rx_size) { + ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size); + } + } +} + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h new file mode 100644 index 00000000000..64522c86bd5 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -0,0 +1,117 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h" +#include "esphome/core/component.h" + +#include +#include +#include "sdkconfig.h" + +namespace esphome::cdc_acm_uart { + +class CDCACMUARTBridge final : public Component { + public: + // Upper bound on the RX task's blocking read, so pause() takes effect without + // aborting the read. Arriving bytes still unblock it immediately. + static constexpr uint32_t UART_RX_WAIT_MS = 250; + + CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent) + : uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {} + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; } + void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; } + + void set_line_coding(); + void set_line_state(bool dtr, bool rts); + + /** + * Stop forwarding in both directions and hand the UART back to its configured + * framing, so another component may use the bus. Main-loop only. The RX task parks + * within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write + * already in flight is allowed to drain first, which at low baud rates can take + * seconds; the framing is restored only after that, so poll is_paused() rather than + * waiting a fixed interval. Host bytes not yet written to the UART are discarded. + * The DTR/RTS outputs hold their state while paused and follow the host again on + * resume(). + */ + void pause(); + /** + * Re-apply the host's line coding and line state, then resume forwarding. Main-loop + * only. Deferred until any host write still draining has finished, so the reload + * never truncates it. + */ + void resume(); + /// True once both worker tasks are off the bus and the configured framing is restored. + /// With no RX task (setup() failed or has not run) there is nothing to wait for. + bool is_paused() const { + return this->state_ == MainState::MAIN_STATE_PAUSED && + (this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0); + } + + protected: + static void uart_rx_task_fn(void *arg); + static void uart_tx_task_fn(void *arg); + void uart_rx_task_(); + void uart_tx_task_(); + void restore_configured_framing_(); + // True when the TX task has no write in flight and the UART TX FIFO has drained. + bool tx_idle_(); + void finish_resume_(); + void drive_line_state_(); + // Copy the host's line coding onto the UART settings; true if anything changed. + bool sync_host_framing_(); + + TaskHandle_t uart_rx_task_handle_{nullptr}; + TaskHandle_t uart_tx_task_handle_{nullptr}; + + GPIOPin *dtr_pin_{nullptr}; + GPIOPin *rts_pin_{nullptr}; + + uint32_t reload_requested_at_{0}; + + // Worker staging, each sized to the CDC ring buffer it feeds or drains. + std::array uart_rx_buffer_{}; + std::array uart_tx_buffer_{}; + + uart::IDFUARTComponent *uart_parent_; + usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_; + + // YAML framing, captured at setup; the host's line coding overwrites the UART's + // settings, so pause() needs the original to restore. + uint32_t configured_baud_rate_{0}; + uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE}; + uint8_t configured_stop_bits_{0}; + uint8_t configured_data_bits_{0}; + + // Written on the main loop, read by both worker tasks. uint8_t rather than bool: + // GCC on Xtensa emits an out-of-line call for atomic. + std::atomic paused_{0}; + // Raised by the RX task while parked and by the TX task around each UART write, so + // the pause hand-off knows when the bus is actually free. + std::atomic rx_parked_{0}; + std::atomic tx_busy_{0}; + // Main-loop state; paused_ mirrors it for the worker tasks. + enum class MainState : uint8_t { + MAIN_STATE_RUNNING, + MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues + MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing + MAIN_STATE_PAUSED, + MAIN_STATE_RESUMING, // resume() requested while a host write still drains + }; + MainState state_{MainState::MAIN_STATE_RUNNING}; + // Host line state, recorded even while paused so resume() can re-drive the pins. + bool host_dtr_{false}; + bool host_rts_{false}; + // True once the host has sent any line coding; resume() then re-syncs to it. + bool host_coding_seen_{false}; +}; + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 6a7bace0a2e..7f0c5bb95e6 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/ch423/__init__.py b/esphome/components/ch423/__init__.py index e3990ee6317..9fbf3ea515e 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/chsc6x/touchscreen.py b/esphome/components/chsc6x/touchscreen.py index 759e38609e5..de974d2a79d 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 fe050fca229..3fbca1a6d03 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 @@ -117,6 +125,19 @@ CLIMATE_SWING_MODES = { validate_climate_swing_mode = cv.enum(CLIMATE_SWING_MODES, upper=True) +ClimateAction = climate_ns.enum("ClimateAction") +CLIMATE_ACTIONS = { + "OFF": ClimateAction.CLIMATE_ACTION_OFF, + "COOLING": ClimateAction.CLIMATE_ACTION_COOLING, + "HEATING": ClimateAction.CLIMATE_ACTION_HEATING, + "IDLE": ClimateAction.CLIMATE_ACTION_IDLE, + "DRYING": ClimateAction.CLIMATE_ACTION_DRYING, + "FAN": ClimateAction.CLIMATE_ACTION_FAN, + "DEFROSTING": ClimateAction.CLIMATE_ACTION_DEFROSTING, +} + +validate_climate_action = cv.enum(CLIMATE_ACTIONS, upper=True) + CONF_MIN_HUMIDITY = "min_humidity" CONF_MAX_HUMIDITY = "max_humidity" CONF_TARGET_HUMIDITY = "target_humidity" @@ -132,7 +153,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,7 +294,7 @@ def climate_schema( @setup_entity("climate") -async def setup_climate_core_(var, config): +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") @@ -443,7 +464,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 +472,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 +506,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 +575,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/climate.cpp b/esphome/components/climate/climate.cpp index b41ca4a5404..f80de151b11 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -368,8 +368,8 @@ optional Climate::restore_state_() { } void Climate::save_state_(const ClimateTraits &traits) { -#if (defined(USE_ESP32) || (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 0, 0))) && \ - !defined(CLANG_TIDY) +#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY) +#pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wclass-memaccess" #define TEMP_IGNORE_MEMACCESS #endif @@ -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; @@ -765,33 +749,39 @@ void Climate::dump_traits_(const char *tag) { } if (!traits.get_supported_modes().empty()) { ESP_LOGCONFIG(tag, " Supported modes:"); - for (ClimateMode m : traits.get_supported_modes()) + for (ClimateMode m : traits.get_supported_modes()) { ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_mode_to_string(m))); + } } if (!traits.get_supported_fan_modes().empty()) { ESP_LOGCONFIG(tag, " Supported fan modes:"); - for (ClimateFanMode m : traits.get_supported_fan_modes()) + for (ClimateFanMode m : traits.get_supported_fan_modes()) { ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_fan_mode_to_string(m))); + } } if (!traits.get_supported_custom_fan_modes().empty()) { ESP_LOGCONFIG(tag, " Supported custom fan modes:"); - for (const char *s : traits.get_supported_custom_fan_modes()) + for (const char *s : traits.get_supported_custom_fan_modes()) { ESP_LOGCONFIG(tag, " - %s", s); + } } if (!traits.get_supported_presets().empty()) { ESP_LOGCONFIG(tag, " Supported presets:"); - for (ClimatePreset p : traits.get_supported_presets()) + for (ClimatePreset p : traits.get_supported_presets()) { ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_preset_to_string(p))); + } } if (!traits.get_supported_custom_presets().empty()) { ESP_LOGCONFIG(tag, " Supported custom presets:"); - for (const char *s : traits.get_supported_custom_presets()) + for (const char *s : traits.get_supported_custom_presets()) { ESP_LOGCONFIG(tag, " - %s", s); + } } if (!traits.get_supported_swing_modes().empty()) { ESP_LOGCONFIG(tag, " Supported swing modes:"); - for (ClimateSwingMode m : traits.get_supported_swing_modes()) + for (ClimateSwingMode m : traits.get_supported_swing_modes()) { ESP_LOGCONFIG(tag, " - %s", LOG_STR_ARG(climate_swing_mode_to_string(m))); + } } } diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 04f653a2b0f..a9068972356 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_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 5315be3db63..0667bd91a26 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 9c832642ce6..255fca9ad12 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 588566dd9d4..bb612eda7bd 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 341f0a4ef1f..c9c0c0c0058 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -21,12 +21,13 @@ class LgIrClimate final : 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 final : 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.cpp b/esphome/components/cm1106/cm1106.cpp index 7e5d25b7aef..2e3352b8953 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -100,7 +100,6 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma void CM1106Component::dump_config() { ESP_LOGCONFIG(TAG, "CM1106:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index 3c82fac977f..a36f0b00594 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"] @@ -43,8 +46,16 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "cm1106", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) -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 +78,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 c39c5924afe..70240eff07b 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/light.py b/esphome/components/color_temperature/light.py index 045ab265cd2..7686ede1558 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/sensor.py b/esphome/components/combination/sensor.py index 327cedee1ef..ccc5a03964a 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 6f4fa9aaa7a..256ab5c0a3c 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -10,11 +10,14 @@ 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_COLUMNS = "columns" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" +CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" @@ -22,23 +25,34 @@ 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_KEYS = "keys" +CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_MANUFACTURER = "manufacturer" +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 1ebcff3c1b5..fcca8b89dba 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.components import climate_ir, remote_base +from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] CODEOWNERS = ["@glmnet"] @@ -10,5 +11,6 @@ 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: + remote_base.request_protocol("coolix") # used from C++ await climate_ir.new_climate_ir(config) diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index 840200409fb..cc8492f21e0 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/button/__init__.py b/esphome/components/copy/button/__init__.py index 8028d6a2175..768131bbe58 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/cover/__init__.py b/esphome/components/copy/cover/__init__.py index ff5bef56688..d23602fa741 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/fan/__init__.py b/esphome/components/copy/fan/__init__.py index a208e5f80af..ffa414c5f21 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/lock/__init__.py b/esphome/components/copy/lock/__init__.py index 46bc08273e1..8d9c4b6eca7 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/number/__init__.py b/esphome/components/copy/number/__init__.py index 3e2bbf2aae9..9659a605f91 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/select/__init__.py b/esphome/components/copy/select/__init__.py index d7ddc52c444..97776b1edd7 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/sensor/__init__.py b/esphome/components/copy/sensor/__init__.py index 57ca06aca7e..54687980475 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/switch/__init__.py b/esphome/components/copy/switch/__init__.py index ee27e38c5fe..0e714540f96 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/text/__init__.py b/esphome/components/copy/text/__init__.py index f1ca404b7b1..59fdce6c96e 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_sensor/__init__.py b/esphome/components/copy/text_sensor/__init__.py index 7b38ff1a643..146beae5eaa 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/cover/__init__.py b/esphome/components/cover/__init__.py index 7639e153344..011b2c2f04c 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 @@ -162,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( @@ -201,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: @@ -235,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) @@ -243,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 @@ -259,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) @@ -267,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) @@ -275,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) @@ -283,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) @@ -421,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/cover.cpp b/esphome/components/cover/cover.cpp index e98a555fe56..dc2db3bf329 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 9a75e684871..8bf45cfb579 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.cpp b/esphome/components/cs5460a/cs5460a.cpp index c9e8f3cf472..1f9233841a6 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -249,7 +249,7 @@ bool CS5460AComponent::check_status_() { bool dir = status & (1 << 21); if (current_gain_ < 0) dir = !dir; - ESP_LOGI(TAG, "Energy counter %s pulse", dir ? "negative" : "positive"); + ESP_LOGI(TAG, "Energy counter %s pulse", dir ? LOG_STR_LITERAL("negative") : LOG_STR_LITERAL("positive")); clear |= 1 << 22; } @@ -319,7 +319,9 @@ void CS5460AComponent::dump_config() { ESP_LOGCONFIG(TAG, "CS5460A:\n" " Init status: %s", - state == COMPONENT_STATE_LOOP ? "OK" : (state == COMPONENT_STATE_FAILED ? "failed" : "other")); + state == COMPONENT_STATE_LOOP + ? LOG_STR_LITERAL("OK") + : (state == COMPONENT_STATE_FAILED ? LOG_STR_LITERAL("failed") : LOG_STR_LITERAL("other"))); LOG_PIN(" CS Pin: ", cs_); ESP_LOGCONFIG(TAG, " Samples / cycle: %" PRIu32 "\n" @@ -330,9 +332,10 @@ void CS5460AComponent::dump_config() { " Current HPF: %s\n" " Voltage HPF: %s\n" " Pulse energy: %.2f Wh", - samples_, phase_offset_, pga_gain_ == CS5460A_PGA_GAIN_50X ? "50x" : "10x", current_gain_, - voltage_gain_, current_hpf_ ? "enabled" : "disabled", voltage_hpf_ ? "enabled" : "disabled", - pulse_energy_wh_); + samples_, phase_offset_, + pga_gain_ == CS5460A_PGA_GAIN_50X ? LOG_STR_LITERAL("50x") : LOG_STR_LITERAL("10x"), current_gain_, + voltage_gain_, current_hpf_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), + voltage_hpf_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"), pulse_energy_wh_); LOG_SENSOR(" ", "Voltage", voltage_sensor_); LOG_SENSOR(" ", "Current", current_sensor_); LOG_SENSOR(" ", "Power", power_sensor_); diff --git a/esphome/components/cs5460a/sensor.py b/esphome/components/cs5460a/sensor.py index 0c6ae0d8217..5f14457101f 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.cpp b/esphome/components/cse7761/cse7761.cpp index 42517515314..103bc844528 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -58,7 +58,6 @@ void CSE7761Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); - this->check_uart_settings(38400, 1, uart::UART_CONFIG_PARITY_EVEN, 8); } void CSE7761Component::update() { diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 7e8caf1ae1e..5f79be0255b 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"] @@ -67,11 +68,17 @@ CONFIG_SCHEMA = ( ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "cse7761", baud_rate=38400, require_rx=True, require_tx=True + "cse7761", + baud_rate=38400, + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, ) -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.cpp b/esphome/components/cse7766/cse7766.cpp index ce77b62b7b4..30f1b7a8679 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -255,7 +255,6 @@ void CSE7766Component::dump_config() { LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_); LOG_SENSOR(" ", "Reactive Power", this->reactive_power_sensor_); LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_); - this->check_uart_settings(4800, 1, uart::UART_CONFIG_PARITY_EVEN); } } // namespace esphome::cse7766 diff --git a/esphome/components/cse7766/sensor.py b/esphome/components/cse7766/sensor.py index 94ed66d7ccf..9bed0f3f59e 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"] @@ -83,11 +84,16 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "cse7766", baud_rate=4800, parity="EVEN", require_rx=True + "cse7766", + baud_rate=4800, + require_rx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, ) -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 324d7947726..7fd81f6c189 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/touchscreen/__init__.py b/esphome/components/cst226/touchscreen/__init__.py index 62c2e3b20af..459cba61cd6 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/cst328/binary_sensor/__init__.py b/esphome/components/cst328/binary_sensor/__init__.py index 6d881cc6c1c..33e68a112b8 100644 --- a/esphome/components/cst328/binary_sensor/__init__.py +++ b/esphome/components/cst328/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 cst328_ns from ..touchscreen import CST328ButtonListener, CST328Touchscreen @@ -22,7 +23,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST328Button).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_CST328_ID]) diff --git a/esphome/components/cst328/touchscreen/__init__.py b/esphome/components/cst328/touchscreen/__init__.py index 18c00bb6c52..9bc7744b7c3 100644 --- a/esphome/components/cst328/touchscreen/__init__.py +++ b/esphome/components/cst328/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 cst328_ns @@ -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]) await touchscreen.register_touchscreen(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/cst816/touchscreen/__init__.py b/esphome/components/cst816/touchscreen/__init__.py index 288ca17593e..029a544a911 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/cst9220/touchscreen/__init__.py b/esphome/components/cst9220/touchscreen/__init__.py index 6d8fc5e2f68..393685e67b3 100644 --- a/esphome/components/cst9220/touchscreen/__init__.py +++ b/esphome/components/cst9220/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 cst9220_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/ct_clamp/sensor.py b/esphome/components/ct_clamp/sensor.py index 6ad7990e809..8ef211cb247 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 99952adb126..a552956082a 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/cwww/light.py b/esphome/components/cwww/light.py index 50d84a582db..90fe6d0bad8 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/d01/__init__.py b/esphome/components/d01/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/d01/d01.cpp b/esphome/components/d01/d01.cpp new file mode 100644 index 00000000000..f7a0c08ec43 --- /dev/null +++ b/esphome/components/d01/d01.cpp @@ -0,0 +1,45 @@ +#include "d01.h" +#include "esphome/core/log.h" + +// uart specification for d01 sensor from https://manuals.plus/ae/1005006417362019: +// +// A frame of serial output data includes 4 bytes, formatted as follows: +// __Characteristic byte: Fixed value 0xA5. +// __Data byte: DATAH is the high 7 bits of the concentration value, and DATAL is the low 7 bits of the concentration +// value. +// __Check byte: The low 7 bits of the sum of all bytes before the check byte. +// +// If the serial output is 4 bytes of data: 0*A5 0*01 0*2C 0*52, then DATAH = 0*01 = 1, DATAL = 0*2C = 44. +// Concentration value = 1*128 + 44 = 172 µg/m³. +// +// The PM2.5 dust concentration value obtained from the dust sensor needs to be calibrated with a K value coefficient +// based on the TSI instrument's photometric method. It is generally recommended to use 0.4. + +namespace esphome::d01 { + +static const char *const TAG = "d01"; + +static const uint8_t D01_FRAME_HEADER = 0xA5; + +void D01SensorComponent::dump_config() { LOG_SENSOR(" ", "D01 PM2.5", this); } + +void D01SensorComponent::loop() { + uint8_t buf[4]; + while (this->available() >= 4) { + if (this->peek() != D01_FRAME_HEADER) { + this->read(); + continue; + } + this->read_array(buf, 4); + uint8_t sum = (buf[0] + buf[1] + buf[2]) & 0x7F; + if (sum != buf[3]) { + ESP_LOGW(TAG, "checksum mismatch"); + continue; + } + uint16_t latest_concentration = (buf[1] & 0x7F) * 128 + (buf[2] & 0x7F); + ESP_LOGV(TAG, "Unadjusted PM2.5 Concentration: %d µg/m³", latest_concentration); + this->publish_state(latest_concentration); + } +} + +} // namespace esphome::d01 diff --git a/esphome/components/d01/d01.h b/esphome/components/d01/d01.h new file mode 100644 index 00000000000..73c7a8711de --- /dev/null +++ b/esphome/components/d01/d01.h @@ -0,0 +1,14 @@ +#pragma once +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::d01 { + +class D01SensorComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { + public: + void dump_config() override; + void loop() override; +}; + +} // namespace esphome::d01 diff --git a/esphome/components/d01/sensor.py b/esphome/components/d01/sensor.py new file mode 100644 index 00000000000..5bc5a4e4248 --- /dev/null +++ b/esphome/components/d01/sensor.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_PM25, + ICON_BLUR, + STATE_CLASS_MEASUREMENT, + UNIT_MICROGRAMS_PER_CUBIC_METER, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@ch604"] +DEPENDENCIES = ["uart"] + +d01_ns = cg.esphome_ns.namespace("d01") +D01SensorComponent = d01_ns.class_( + "D01SensorComponent", sensor.Sensor, uart.UARTDevice, cg.Component +) + + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + D01SensorComponent, + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_BLUR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_PM25, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "d01", + baud_rate=9600, + require_rx=True, + require_tx=False, +) + + +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/dac7678/__init__.py b/esphome/components/dac7678/__init__.py index 842c84832ea..668cc87cece 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/output.py b/esphome/components/dac7678/output.py index cb7739242c3..8bc9e119c2f 100644 --- a/esphome/components/dac7678/output.py +++ b/esphome/components/dac7678/output.py @@ -2,6 +2,8 @@ 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.cpp_generator import MockObj +from esphome.types import ConfigType from . import DAC7678Output, dac7678_ns @@ -19,7 +21,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> 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 7f0226143bb..c9f9cb189f0 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_arc/climate.py b/esphome/components/daikin_arc/climate.py index dbaf12d9596..210ec6987ed 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_brc/climate.py b/esphome/components/daikin_brc/climate.py index 5b7a4631a93..c5c1d3739ee 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/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index ab4a8c458fe..c418362ced1 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -6,6 +6,7 @@ namespace esphome::dallas_temp { static const char *const TAG = "dallas.temp.sensor"; static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; +static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_COMMAND_START_CONVERSION = 0x44; static const uint8_t DALLAS_COMMAND_READ_SCRATCH_PAD = 0xBE; static const uint8_t DALLAS_COMMAND_WRITE_SCRATCH_PAD = 0x4E; @@ -154,7 +155,14 @@ float DallasTemperatureSensor::get_temp_c_() { default: break; } - + // undocumented test for powerup measurement of 85 + // https://github.com/cpetrich/counterfeit_DS18B20#solution-to-the-85-c-problem + if ((this->address_ & 0xff) == DALLAS_MODEL_DS18B20) { + if ((temp == 85 * 16) && (this->scratch_pad_[6] == 0xc)) { + ESP_LOGD(TAG, "dropping reading caused by sensor reset"); + return NAN; + } + } return temp / 16.0f; } diff --git a/esphome/components/dallas_temp/sensor.py b/esphome/components/dallas_temp/sensor.py index 3d358817225..c4415049473 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 87f00ce507a..c0d7d0aa624 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 @@ -25,8 +26,16 @@ CONFIG_SCHEMA = ( .extend(cv.polling_component_schema("30s")) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "daly_bms", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) -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 95a2ae3b44e..2b6ceffff1f 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.cpp b/esphome/components/daly_bms/daly_bms.cpp index 530d8ad5415..45d4db49721 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -22,10 +22,7 @@ static const uint8_t DALY_REQUEST_TEMPERATURE = 0x96; void DalyBmsComponent::setup() { this->next_request_ = 1; } -void DalyBmsComponent::dump_config() { - ESP_LOGCONFIG(TAG, "Daly BMS:"); - this->check_uart_settings(9600); -} +void DalyBmsComponent::dump_config() { ESP_LOGCONFIG(TAG, "Daly BMS:"); } void DalyBmsComponent::update() { this->trigger_next_ = true; diff --git a/esphome/components/daly_bms/sensor.py b/esphome/components/daly_bms/sensor.py index aa92cfa86a7..3e91fb280ad 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 9f4e2df85a0..1a91081bbf0 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 000db307b96..906e9762bfa 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,7 +74,7 @@ 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]: @@ -108,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/datetime/__init__.py b/esphome/components/datetime/__init__.py index 87997daa3d9..f8b6446006e 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 997aec3f696..b99b89259fb 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 9b86c122285..93ce1411f87 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -96,6 +96,8 @@ class DateCall { optional day_; }; +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_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index a8e00d6eb3e..8f180fd081e 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 159e4ccc6f5..fec620b5ba9 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -121,6 +121,8 @@ class DateTimeCall { optional second_; }; +inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); } + template class DateTimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, datetime) diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index 1cc9eaf2fb6..da4c9eb31e4 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 643f4bd176a..736e26f4a7a 100644 --- a/esphome/components/datetime/time_entity.h +++ b/esphome/components/datetime/time_entity.h @@ -98,6 +98,8 @@ class TimeCall { optional second_; }; +inline TimeCall TimeEntity::make_call() { return TimeCall(this); } + template class TimeSetAction final : public Action, public Parented { public: TEMPLATABLE_VALUE(ESPTime, time) diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index 3e94d04f21c..a889d13329b 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 diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 9020c261c26..97f4522c623 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -22,9 +22,9 @@ void DebugComponent::dump_config() { LOG_SENSOR(" ", "Free space on heap", this->free_sensor_); LOG_SENSOR(" ", "Largest free heap block", this->block_sensor_); LOG_SENSOR(" ", "CPU frequency", this->cpu_frequency_sensor_); -#if defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) +#ifdef USE_ESP8266 LOG_SENSOR(" ", "Heap fragmentation", this->fragmentation_sensor_); -#endif // defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) +#endif // USE_ESP8266 #endif // USE_SENSOR char device_info_buffer[DEVICE_INFO_BUFFER_SIZE]; diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index 20798cf6006..b05029f8785 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -35,7 +35,7 @@ class DebugComponent final : public PollingComponent { #ifdef USE_SENSOR void set_free_sensor(sensor::Sensor *free_sensor) { free_sensor_ = free_sensor; } void set_block_sensor(sensor::Sensor *block_sensor) { block_sensor_ = block_sensor; } -#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_ESP32) void set_fragmentation_sensor(sensor::Sensor *fragmentation_sensor) { fragmentation_sensor_ = fragmentation_sensor; } #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) @@ -61,7 +61,7 @@ class DebugComponent final : public PollingComponent { sensor::Sensor *free_sensor_{nullptr}; sensor::Sensor *block_sensor_{nullptr}; -#if (defined(USE_ESP8266) && USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2)) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_ESP32) sensor::Sensor *fragmentation_sensor_{nullptr}; #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 7c01f9b54fe..8e1a67224eb 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 @@ -65,11 +66,15 @@ const char *DebugComponent::get_reset_reason_(std::spanmake_preference(REBOOT_MAX_LEN, fnv1_hash_extend(fnv1_hash(REBOOT_KEY), App.get_name().c_str())); char reboot_source[REBOOT_MAX_LEN]{}; - if (pref.load(&reboot_source)) { + if (pref.load(&reboot_source) && reboot_source[0] != '\0') { reboot_source[REBOOT_MAX_LEN - 1] = '\0'; snprintf(buf, size, "Reboot request from %s", reboot_source); } else { @@ -249,7 +254,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_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index 272123dfc0e..acce28818c7 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -159,12 +159,10 @@ void DebugComponent::update_platform_() { // NOLINTNEXTLINE(readability-static-accessed-through-instance) this->block_sensor_->publish_state(ESP.getMaxFreeBlockSize()); } -#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 5, 2) if (this->fragmentation_sensor_ != nullptr) { // NOLINTNEXTLINE(readability-static-accessed-through-instance) this->fragmentation_sensor_->publish_state(ESP.getHeapFragmentation()); } -#endif #endif } diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index 336e9c7e068..4ace4be0a3d 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,8 +1,9 @@ #include "debug_component.h" #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 @@ -68,13 +69,14 @@ 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 a018ce5c3bd..80d1daa81f1 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -21,12 +21,9 @@ from esphome.const import ( UNIT_MILLISECOND, UNIT_PERCENT, ) +from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] @@ -51,12 +48,9 @@ CONFIG_SCHEMA = { ), cv.Optional(CONF_FRAGMENTATION): cv.All( cv.Any( - cv.All( - cv.only_on_esp8266, - cv.require_framework_version(esp8266_arduino=cv.Version(2, 5, 2)), - ), + cv.only_on_esp8266, cv.only_on_esp32, - msg="This feature is only available on ESP8266 (Arduino 2.5.2+) and ESP32", + msg="This feature is only available on ESP8266 and ESP32", ), sensor.sensor_schema( unit_of_measurement=UNIT_PERCENT, @@ -111,7 +105,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 c69b8d9461e..2e02af67cbe 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -7,12 +7,9 @@ from esphome.const import ( ICON_CHIP, ICON_RESTART, ) +from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] @@ -33,7 +30,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 3b70f947d27..dc037086452 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -38,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 = { @@ -162,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) @@ -174,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": @@ -345,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) @@ -458,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: @@ -487,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 73e0331c76a..a955095875b 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ 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() { @@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } } diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index e7ce70b60c3..d33102bf4fa 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -17,6 +17,7 @@ void DeepSleepComponent::setup() { void DeepSleepComponent::schedule_sleep_() { this->next_enter_deep_sleep_ = false; + this->disable_loop(); const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -43,13 +44,9 @@ 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; + this->defer_sleep_(); return; } @@ -76,8 +73,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 a620d52a027..0bbca4c5c4d 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -132,7 +132,7 @@ template class PreventDeepSleepAction; 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. @@ -157,7 +157,7 @@ class DeepSleepComponent final : public Component { #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) - void set_touch_wakeup(bool touch_wakeup); + 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 @@ -166,7 +166,7 @@ class DeepSleepComponent final : 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; @@ -176,8 +176,8 @@ class DeepSleepComponent final : 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 @@ -190,6 +190,11 @@ class DeepSleepComponent final : public Component { void schedule_sleep_(); bool should_teardown_(); + void defer_sleep_() { + this->next_enter_deep_sleep_ = true; + this->enable_loop(); + } + #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index f64e1f37e18..20297028b2c 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -74,12 +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_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) -void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } -#endif - void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) { wakeup_cause_to_run_duration_ = wakeup_cause_to_run_duration; } @@ -106,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGW(TAG, "Waiting for wakeup pin state change"); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } return true; diff --git a/esphome/components/delonghi/climate.py b/esphome/components/delonghi/climate.py index 63576f032d2..919bf7b806c 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/demo/__init__.py b/esphome/components/demo/__init__.py index 2af0c18c186..75feaa65af1 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/dew_point/sensor.py b/esphome/components/dew_point/sensor.py index 4fee0956021..555fdef289e 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 d589381461f..bb18e6ba8c6 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -60,7 +60,12 @@ CONFIG_SCHEMA = cv.All( ).extend(uart.UART_DEVICE_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "dfplayer", baud_rate=9600, require_tx=True + "dfplayer", + baud_rate=9600, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, ) diff --git a/esphome/components/dfplayer/dfplayer.cpp b/esphome/components/dfplayer/dfplayer.cpp index 5c9d497c877..f81d1cd1b6a 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -277,9 +277,6 @@ void DFPlayer::loop() { } } } -void DFPlayer::dump_config() { - ESP_LOGCONFIG(TAG, "DFPlayer:"); - this->check_uart_settings(9600); -} +void DFPlayer::dump_config() { ESP_LOGCONFIG(TAG, "DFPlayer:"); } } // namespace esphome::dfplayer diff --git a/esphome/components/dfrobot_sen0395/__init__.py b/esphome/components/dfrobot_sen0395/__init__.py index 943c510279e..51562f923cb 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/binary_sensor.py b/esphome/components/dfrobot_sen0395/binary_sensor.py index 193ef925a4b..e299c35a42e 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/switch/__init__.py b/esphome/components/dfrobot_sen0395/switch/__init__.py index 8e492080de4..22aaa1640cc 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/dht/dht.cpp b/esphome/components/dht/dht.cpp index 5b7b6a268fa..2196f3a982c 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -20,8 +20,8 @@ void DHT::dump_config() { "DHT:\n" " %sModel: %s\n" " Internal pull-up: %s", - this->is_auto_detect_ ? "Auto-detected " : "", - this->model_ == DHT_MODEL_DHT11 ? "DHT11" : "DHT22 or equivalent", + this->is_auto_detect_ ? LOG_STR_LITERAL("Auto-detected ") : "", + this->model_ == DHT_MODEL_DHT11 ? LOG_STR_LITERAL("DHT11") : LOG_STR_LITERAL("DHT22 or equivalent"), ONOFF(this->t_pin_->get_flags() & gpio::FLAG_PULLUP)); LOG_PIN(" Pin: ", this->t_pin_); LOG_UPDATE_INTERVAL(this); @@ -154,8 +154,9 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r } } if (error_code != 0) { - if (report_errors) + if (report_errors) { ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + } return false; } diff --git a/esphome/components/dht/sensor.py b/esphome/components/dht/sensor.py index d907495ba2e..7376adb2874 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/sensor.py b/esphome/components/dht12/sensor.py index eb93cbae2cf..2bc6e945153 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/display.cpp b/esphome/components/display/display.cpp index 115adf503a5..c2d45dbb600 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -685,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(); @@ -892,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 3a136937f6a..a9ffda422d1 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -802,9 +802,9 @@ class DisplayPage final { 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,6 +814,9 @@ class DisplayPage final { DisplayPage *next_{nullptr}; }; +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) diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 9125c43f0c7..2120abe5f79 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/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index b747f73a14b..00a1694cc32 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,5 +1,6 @@ import logging import re +from typing import Any import esphome.codegen as cg from esphome.components import esp32, uart @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -33,13 +35,13 @@ DlmsMeterComponent = dlms_meter_component_ns.class_( ) -def obis_code(value): +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): +def parse_obis_code_bytes(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -57,19 +59,19 @@ def parse_obis_code_bytes(value): return bytes_list -def custom_pattern_dict(value): +def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(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): +def validate_provider_deprecation(config: ConfigType) -> ConfigType: if CONF_PROVIDER in config: provider = str(config[CONF_PROVIDER]).lower() if provider == "netznoe": @@ -154,7 +156,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) -async def to_code(config): +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)] diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index f9bc1d9df74..a15e58b9571 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/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_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -14,7 +15,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_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/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index ec4639351d9..8ded150cd09 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( UNIT_WATT, UNIT_WATT_HOURS, ) +from esphome.types import ConfigType from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -47,7 +48,7 @@ DYNAMIC_SCHEMA = sensor.sensor_schema().extend( ) -def deprecation_warning(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'." @@ -145,7 +146,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index 0bfb43a285b..c2ff0779eee 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -3,6 +3,7 @@ import logging 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_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code @@ -23,7 +24,7 @@ DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( ) -def deprecation_warning(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'." @@ -46,7 +47,7 @@ OLD_SCHEMA = cv.All( CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) -async def to_code(config): +async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) if obis := config.get(CONF_OBIS_CODE): diff --git a/esphome/components/dps310/sensor.py b/esphome/components/dps310/sensor.py index 605812beaac..8b8fd8373ba 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/time.py b/esphome/components/ds1307/time.py index 0e7bb976a2d..a3ae3eb5af8 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/ds1603l/__init__.py b/esphome/components/ds1603l/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/ds1603l/ds1603l.cpp b/esphome/components/ds1603l/ds1603l.cpp new file mode 100644 index 00000000000..b0b0ef8175d --- /dev/null +++ b/esphome/components/ds1603l/ds1603l.cpp @@ -0,0 +1,68 @@ +#include "ds1603l.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ds1603l { + +static const char *const TAG = "ds1603l.sensor"; + +void DS1603L::loop() { + // Assemble frames one byte at a time so a stream that starts mid-frame can realign + uint8_t byte; + while (this->available() > 0 && this->read_byte(&byte)) { + if (this->rx_count_ == 0 && byte != HEADER_BYTE) { + ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte); + continue; + } + + this->rx_buffer_[this->rx_count_++] = byte; + if (this->rx_count_ < FRAME_SIZE) { + continue; + } + + if (this->parse_data_()) { + this->rx_count_ = 0; + } else { + // The header byte was part of the payload of a misaligned frame, so realign instead of dropping everything + this->resync_(); + } + } +} + +void DS1603L::dump_config() { LOG_SENSOR("", "DS1603L", this); } + +bool DS1603L::parse_data_() { + uint8_t header = this->rx_buffer_[0]; + uint8_t data_h = this->rx_buffer_[1]; + uint8_t data_l = this->rx_buffer_[2]; + uint8_t checksum = this->rx_buffer_[3]; + + uint8_t computed_checksum = (header + data_h + data_l) & 0xFF; + + ESP_LOGV(TAG, "Data: Header=0x%02X, Data_H=0x%02X, Data_L=0x%02X, Checksum=0x%02X", header, data_h, data_l, checksum); + + if (checksum != computed_checksum) { + ESP_LOGW(TAG, "Checksum mismatch: received 0x%02X, expected 0x%02X", checksum, computed_checksum); + return false; + } + + this->publish_state(encode_uint16(data_h, data_l)); + return true; +} + +void DS1603L::resync_() { + // Drop the byte that was treated as the header, then look for the next candidate header in what is left + size_t start = 1; + while (start < this->rx_count_ && this->rx_buffer_[start] != HEADER_BYTE) { + start++; + } + this->rx_count_ -= start; + if (this->rx_count_ > 0) { + memmove(this->rx_buffer_, this->rx_buffer_ + start, this->rx_count_); + } +} + +} // namespace esphome::ds1603l diff --git a/esphome/components/ds1603l/ds1603l.h b/esphome/components/ds1603l/ds1603l.h new file mode 100644 index 00000000000..9041681f5ee --- /dev/null +++ b/esphome/components/ds1603l/ds1603l.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" + +namespace esphome::ds1603l { + +class DS1603L final : public sensor::Sensor, public Component, public uart::UARTDevice { + public: + void loop() override; + void dump_config() override; + + protected: + static constexpr uint8_t HEADER_BYTE = 0xFF; + static constexpr size_t FRAME_SIZE = 4; + + // Validates the checksum of the frame in rx_buffer_ and publishes it. Returns false if the frame is invalid. + bool parse_data_(); + // Drops the first buffered byte and realigns the buffer on the next possible header byte. + void resync_(); + + uint8_t rx_buffer_[FRAME_SIZE]; // Buffer for the frame being assembled + size_t rx_count_{0}; // Number of bytes currently in rx_buffer_ +}; + +} // namespace esphome::ds1603l diff --git a/esphome/components/ds1603l/sensor.py b/esphome/components/ds1603l/sensor.py new file mode 100644 index 00000000000..c4f117c6035 --- /dev/null +++ b/esphome/components/ds1603l/sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DISTANCE, + STATE_CLASS_MEASUREMENT, + UNIT_MILLIMETER, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@JakeLC15"] +DEPENDENCIES = ["uart"] + +ds1603l_ns = cg.esphome_ns.namespace("ds1603l") +DS1603L = ds1603l_ns.class_("DS1603L", sensor.Sensor, cg.Component, uart.UARTDevice) + + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + DS1603L, + unit_of_measurement=UNIT_MILLIMETER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DISTANCE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "ds1603l", + baud_rate=9600, + require_tx=False, + require_rx=True, + data_bits=8, + stop_bits=1, +) + + +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/ds2484/one_wire.py b/esphome/components/ds2484/one_wire.py index 384b2d01e69..f6277cd68eb 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 index 5a26ceab504..a2e2a87ed0c 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__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_SLEEP_PIN, CONF_TYPE +from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] MULTI_CONF = True @@ -35,7 +36,7 @@ ds248x_ns = cg.esphome_ns.namespace("ds248x") DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice) -def _component_schema(*extras): +def _component_schema(*extras: dict) -> cv.Schema: schema = cv.Schema( { cv.GenerateID(): cv.declare_id(DS248xComponent), @@ -79,11 +80,11 @@ CONFIG_SCHEMA = cv.typed_schema( ) -def get_channel_count(config): +def get_channel_count(config: ConfigType) -> int: return CHANNEL_COUNTS[config[CONF_TYPE]] -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/ds248x/one_wire.py b/esphome/components/ds248x/one_wire.py index 19861eae364..b0289581329 100644 --- a/esphome/components/ds248x/one_wire.py +++ b/esphome/components/ds248x/one_wire.py @@ -12,6 +12,7 @@ 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 @@ -29,7 +30,7 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) -def _final_validate(config): +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] @@ -47,7 +48,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) diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 34f37ace35f..96a1e756685 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -60,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]) @@ -88,7 +88,7 @@ async def to_code(config): 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"]: @@ -102,7 +102,5 @@ def final_validate(config: ConfigType) -> ConfigType: ) break - return config - FINAL_VALIDATE_SCHEMA = final_validate diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 7d93ee62e15..6aecc62d6bb 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 @@ -812,7 +813,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]) sensors = [] diff --git a/esphome/components/dsmr/text_sensor.py b/esphome/components/dsmr/text_sensor.py index 54b5711923e..4945ba965cc 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 @@ -39,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/sensor.py b/esphome/components/duty_cycle/sensor.py index 37c889cd85b..b7aa1777c83 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/sensor.py b/esphome/components/duty_time/sensor.py index 456859f8e49..6d878a80a5e 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 a1a8e0aec59..3b1eb99e60b 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/ee895/sensor.py b/esphome/components/ee895/sensor.py index 8c9c7e72381..fdad47fb05c 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 64bb17a7db6..7636b6993a1 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/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 323195e99af..639847345f2 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 f46082f5e7c..bb041bfd6d5 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -93,7 +93,7 @@ void Emc2101Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } - ESP_LOGCONFIG(TAG, " Mode: %s", this->dac_mode_ ? "DAC" : "PWM"); + ESP_LOGCONFIG(TAG, " Mode: %s", this->dac_mode_ ? LOG_STR_LITERAL("DAC") : LOG_STR_LITERAL("PWM")); if (this->dac_mode_) { ESP_LOGCONFIG(TAG, " DAC Conversion Rate: %X", this->dac_conversion_rate_); } else { diff --git a/esphome/components/emc2101/output/__init__.py b/esphome/components/emc2101/output/__init__.py index 586f0800a6f..a8820345e2d 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/sensor/__init__.py b/esphome/components/emc2101/sensor/__init__.py index b6a2c8a3331..cc8901cf389 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/emmeti/climate.py b/esphome/components/emmeti/climate.py index 56e8e2b8048..ea446063005 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/emontx/__init__.py b/esphome/components/emontx/__init__.py index a2d4349698a..3821f3e10e2 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/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 83a972c5e0e..56a7fb8b559 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/endstop/cover.py b/esphome/components/endstop/cover.py index c16680b6af2..0e27189500c 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/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 46c53c3b10c..1bdfb0c0a6b 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/sensor.py b/esphome/components/ens160_i2c/sensor.py index cad4e81afc2..398b9b48045 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/sensor.py b/esphome/components/ens160_spi/sensor.py index 1bda05c7bb9..cc6a90a33e0 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.cpp b/esphome/components/ens210/ens210.cpp index 468c627d4b5..11b73afe377 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -216,7 +216,7 @@ void ENS210Component::extract_measurement_(uint32_t val, int *data, int *status) // Sets ENS210 to low (true) or high (false) power. Returns false on I2C problems. bool ENS210Component::set_low_power_(bool enable) { uint8_t low_power_cmd = enable ? 0x01 : 0x00; - ESP_LOGD(TAG, "Enable low power: %s", enable ? "true" : "false"); + ESP_LOGD(TAG, "Enable low power: %s", enable ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false")); bool result = this->write_byte(ENS210_REGISTER_SYS_CTRL, low_power_cmd); delay(ENS210_BOOTING_MS); return result; diff --git a/esphome/components/ens210/sensor.py b/esphome/components/ens210/sensor.py index 289a5596739..bfd758f92f3 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 d4ffd034a10..7b7c48c0b0f 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -81,4 +81,134 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C 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 0b82850f1ef..e9da924de57 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -153,7 +153,7 @@ def customise_schema(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) @@ -170,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 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 00000000000..820dc3c77ae --- /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 00000000000..6eebe24c91f --- /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_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp new file mode 100644 index 00000000000..b81542b6c14 --- /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 00000000000..8b00552ff8f --- /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_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 1ef2dd12c38..f47d37d5504 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 9c251068af8..e1e3d3d763d 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 index 5735333761b..95d1fcb4846 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -311,9 +311,12 @@ bool HOT EPaperT133A01::transfer_data() { this->current_data_index_ = half; if (millis() - start_time > MAX_TRANSFER_TIME) { - return false; + break; } } + if (half < total_rows) { + return false; + } ESP_LOGD(TAG, "CS phase done"); this->disable(); this->cs_pin_->digital_write(true); // deselect CS @@ -346,9 +349,12 @@ bool HOT EPaperT133A01::transfer_data() { this->current_data_index_ = half; if (millis() - start_time > MAX_TRANSFER_TIME) { - return false; + break; } } + if (half < total_rows * 2) { + return false; + } ESP_LOGD(TAG, "CS1 phase done"); this->disable(); this->cs1_pin_->digital_write(true); // deselect CS1 diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp new file mode 100644 index 00000000000..2a4ff2969aa --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -0,0 +1,139 @@ +#include "epaper_spi_uc8179.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { + +static constexpr const char *const TAG = "epaper_spi.uc8179"; + +bool EPaperUC8179::initialise(bool partial) { + EPaperBase::initialise(partial); // send the model init sequence + this->partial_ = partial; + ESP_LOGV(TAG, "Power on"); + // POWER ON must precede the waveform/mode registers and the data transfer + // (the original driver powers on and busy-waits before writing them). + // The state machine busy-waits before entering TRANSFER_DATA. + this->command(0x04); + // Give the busy line time to assert before the state machine polls it + this->next_delay_ = 100; + return true; +} + +// Set up the refresh mode. Must be called after power-on has completed. +void EPaperUC8179::set_refresh_mode_() { + if (!this->is_using_partial_update_()) { + return; // plain full refresh uses the mode set by the init sequence + } + // Fast and partial refresh use flipped data polarity and a floating border + this->cmd_data(0x50, {0xA9, 0x07}); + // Force the waveform via the temperature registers: 0x5A selects the fast + // full-refresh waveform, 0x6E the partial-refresh waveform + this->cmd_data(0xE0, {0x02}); + if (this->partial_) { + this->cmd_data(0xE5, {0x6E}); + this->command(0x91); // enter partial mode + // Set the partial window to the full screen + const uint16_t x_end = this->width_ - 1; + const uint16_t y_end = this->height_ - 1; + this->cmd_data(0x90, {0x00, 0x00, static_cast(x_end >> 8), static_cast(x_end & 0xFF), 0x00, 0x00, + static_cast(y_end >> 8), static_cast(y_end & 0xFF), 0x01}); + } else { + this->cmd_data(0xE5, {0x5A}); + this->command(0x92); // exit partial mode + } +} + +bool HOT EPaperUC8179::transfer_data() { + const uint32_t start_time = millis(); + const size_t buffer_length = this->buffer_length_; + if (this->current_data_index_ == 0) { + this->set_refresh_mode_(); + } + // Fast full refresh sends the previous-image plane as well, so that every pixel transitions + const bool two_pass = this->is_using_partial_update_() && !this->partial_; + // Plain full refresh sends inverted data (buffer is 1=white, the wire wants 0=white); + // in fast/partial mode the data polarity is flipped via the VCOM/data-interval + // register instead, so the new-image plane is sent unmodified + const bool invert_new_data = !this->is_using_partial_update_(); + + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + + // Phase 1 (fast full refresh only): previous image via 0x10 (DTM1), inverse of the new image + if (two_pass && this->current_data_index_ < buffer_length) { + if (this->current_data_index_ == 0) { + this->command(0x10); // DATA START TRANSMISSION 1 (previous image) + } + 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(); + } + + // Phase 2: new image via 0x13 (DTM2) + const size_t offset = two_pass ? buffer_length : 0; + const size_t total = offset + buffer_length; + if (this->current_data_index_ < total) { + if (this->current_data_index_ == offset) { + this->command(0x13); // DATA START TRANSMISSION 2 (new image) + } + this->start_data_(); + while (this->current_data_index_ < total) { + const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, total - this->current_data_index_); + const size_t data_idx = this->current_data_index_ - offset; + for (size_t i = 0; i < bytes_to_copy; i++) { + const uint8_t byte = this->buffer_[data_idx + i]; + bytes_to_send[i] = invert_new_data ? ~byte : byte; + } + 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 EPaperUC8179::power_on() { + // Power-on is sent at the end of initialise() instead, because the + // waveform/mode registers and the data transfer must follow it +} + +void EPaperUC8179::refresh_screen(bool /*partial*/) { + ESP_LOGV(TAG, "Refresh"); + this->command(0x12); // DISPLAY REFRESH + // Delay the next busy poll: the busy line takes a short time to assert after + // the refresh command, and polling too early would read it as already idle + this->next_delay_ = 100; +} + +void EPaperUC8179::power_off() { + ESP_LOGV(TAG, "Power off"); + this->command(0x02); // POWER OFF +} + +void EPaperUC8179::deep_sleep() { + // Deep sleep loses the previous-image RAM that partial refresh compares against + if (!this->is_using_partial_update_()) { + ESP_LOGV(TAG, "Deep sleep"); + this->cmd_data(0x07, {0xA5}); // DEEP SLEEP with check code + } +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h new file mode 100644 index 00000000000..85c0eb623ee --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -0,0 +1,52 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Monochrome e-paper displays using the UC8179 controller. + * Supports: 7.5" V2 (EPD_7in5_V2), 800x480 pixels, as used by the + * Waveshare 7.5" V2 HAT and the Seeed reTerminal E1001. + * + * Buffer layout: 1 bit per pixel, 1=white, 0=black (the base class default). + * + * The INITIALISE state sends the panel configuration followed by power-on + * (0x04); the state machine busy-waits for power-on to complete before + * TRANSFER_DATA, which first writes the waveform/mode registers (these are + * only accepted while powered) and then the image data. The state machine + * busy-waits again before triggering REFRESH_SCREEN (0x12). + * + * Three refresh modes are used, following the Waveshare EPD_7in5_V2 examples: + * - full_update_every == 1: plain full refresh. The new image is sent + * inverted to DTM2 (0x13) and the controller uses its normal waveform. + * - full_update_every > 1, full update: fast full refresh. The data polarity + * is flipped via the VCOM/data-interval register, a fast waveform is forced + * via the temperature registers, and the image is sent to both DTM1 (0x10, + * inverted) and DTM2 (0x13) so that every pixel transitions. + * - full_update_every > 1, partial update: partial refresh. A partial-update + * waveform is forced, partial mode is entered with a full-screen window and + * only DTM2 is sent; the controller compares against its previous-image RAM. + */ +class EPaperUC8179 final : public EPaperBase { + public: + EPaperUC8179(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; + } + + protected: + bool initialise(bool partial) override; + bool transfer_data() override; + void refresh_screen(bool partial) override; + void power_on() override; + void power_off() override; + void deep_sleep() override; + void set_refresh_mode_(); + + // Set by initialise() so transfer_data() knows which planes to send + bool partial_{}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/__init__.py b/esphome/components/epaper_spi/models/__init__.py index 2360b090ffb..34e65061f31 100644 --- a/esphome/components/epaper_spi/models/__init__.py +++ b/esphome/components/epaper_spi/models/__init__.py @@ -15,7 +15,7 @@ class EpaperModel: self, name: str, class_name: str, - initsequence=None, + initsequence=(), **defaults, ): name = name.upper() diff --git a/esphome/components/epaper_spi/models/inkplate6color.py b/esphome/components/epaper_spi/models/inkplate6color.py new file mode 100644 index 00000000000..33fecab8489 --- /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/uc8179.py b/esphome/components/epaper_spi/models/uc8179.py new file mode 100644 index 00000000000..bea133c3282 --- /dev/null +++ b/esphome/components/epaper_spi/models/uc8179.py @@ -0,0 +1,93 @@ +"""Monochrome e-paper displays using the UC8179 controller. + +Supported models: +- waveshare-7.5in-v2: 7.5" mono display, 800x480 pixels (EPD_7in5_V2) +- seeed-reterminal-e1001: Seeed reTerminal E1001, which uses the same + 7.5" 800x480 panel on an integrated ESP32-S3 board + +Panel configuration and power-on (0x04) are both sent during the INITIALISE +state; the state machine's built-in busy wait then covers the power-on delay +before the waveform/mode registers and image data are transferred. + +These displays support fast full and partial refresh: set ``full_update_every`` +greater than 1 to enable it. Every ``full_update_every``-th update is a fast +full refresh, with partial refreshes in between. +""" + +from typing import Any + +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class UC8179(EpaperModel): + """EpaperModel class for monochrome displays using the UC8179 controller.""" + + def __init__( + self, + name: str, + class_name: str = "EPaperUC8179", + data_rate: str = "10MHz", + **defaults: Any, + ) -> None: + defaults.setdefault(CONF_DATA_RATE, data_rate) + super().__init__(name, class_name, **defaults) + + def get_init_sequence(self, config: dict) -> tuple: + """Generate the initialization sequence for UC8179 mono displays. + + Panel configuration only — the driver appends power-on (0x04) at the + end of the INITIALISE state, and the state machine busy-waits for it + to complete before the data transfer starts. + """ + width, height = self.get_dimensions(config) + return ( + # POWER SETTING + (0x01, 0x07, 0x07, 0x3F, 0x3F), + # BOOSTER SOFT START + (0x06, 0x17, 0x17, 0x28, 0x17), + # PANEL SETTING (black/white mode, LUT from OTP) + (0x00, 0x1F), + # 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, 0x10, 0x07), + # TCON SETTING + (0x60, 0x22), + ) + + +uc8179 = UC8179("uc8179") + +# Waveshare 7.5" V2 mono (EPD_7in5_V2) — 800x480, UC8179 controller +waveshare_7_5_v2 = uc8179.extend( + "waveshare-7.5in-v2", + width=800, + height=480, +) + +# Seeed reTerminal E1001 — 7.5" mono e-paper (800x480), same panel as the +# Waveshare 7.5" V2, driven by an integrated ESP32-S3 board +waveshare_7_5_v2.extend( + "seeed-reterminal-e1001", + cs_pin=10, + dc_pin=11, + reset_pin=12, + busy_pin={ + "number": 13, + "inverted": True, + "mode": { + "input": True, + "pullup": True, + }, + }, +) diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index f0bd8bc25ab..2defdb0c35c 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 892b67b270a..5afc22aec4f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); // Configure mic 3 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); // Configure mic 4 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); diff --git a/esphome/components/es7243e/audio_adc.py b/esphome/components/es7243e/audio_adc.py index c305d60172a..49161339822 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/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index c5fb6096da8..305aa921255 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/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 5941a819351..f9cfb822cca 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/es8388/audio_dac.py b/esphome/components/es8388/audio_dac.py index 77e07b2e01a..2616cbfa536 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/select/__init__.py b/esphome/components/es8388/select/__init__.py index 068d9f9fb8d..b81bcd13cfb 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/esp32/__init__.py b/esphome/components/esp32/__init__.py index 7e13156b4d9..d027c9a1c61 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -12,6 +12,7 @@ from typing import Any from esphome import yaml_util import esphome.codegen as cg from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -64,6 +65,7 @@ from .boards import BOARDS, STANDARD_BOARDS from .const import ( KEY_ARDUINO_LIBRARIES, KEY_BOARD, + KEY_CERT_BUNDLE, KEY_COMPONENTS, KEY_ESP32, KEY_EXCLUDE_COMPONENTS, @@ -119,6 +121,7 @@ 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}" @@ -141,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 @@ -169,6 +182,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = { VARIANT_ESP32, } +# Variants that support execution from PSRAM +PSRAM_XIP_VARIANTS = { + VARIANT_ESP32S3, + VARIANT_ESP32P4, + VARIANT_ESP32S31, +} + +# Variants whose ROM exports a full-format vsnprintf but no vasprintf +# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only +# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp. +# The other variants either export both (classic ESP32, nano-format only) or +# neither, so the engine is already in the image and the wrap saves nothing. +ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} + # 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 @@ -193,38 +220,67 @@ 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 + "bt", # Bluetooth stack - re-included by request_bluetooth(); its REQUIRES pulls the WiFi stack back "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_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back + "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_hal_ieee802154", # 802.15.4 HAL - ieee802154 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 + "esp_phy", # RF PHY - re-included by internal_temperature on the original ESP32; esp_wifi/bt/ieee802154 pull it back + "esp_wifi", # WiFi stack - re-included by request_wifi(), espnow; bt pulls it back for BLE builds "espcoredump", # Core dump support - ESPHome has its own debug component "fatfs", # FAT filesystem - ESPHome doesn't use filesystem storage + "ieee802154", # 802.15.4 radio - IDF openthread and the Zigbee libs pull it back + "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 "wifi_provisioning", # WiFi provisioning - ESPHome uses its own improv implementation + "wpa_supplicant", # WPA supplicant - re-included by request_wifi() for esp_eap_client.h ) # Additional IDF managed components to exclude for Arduino framework builds @@ -311,6 +367,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 @@ -559,6 +619,9 @@ def get_download_types(storage_json): 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)", @@ -609,6 +672,27 @@ 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 @@ -628,7 +712,6 @@ class NetworkSdkconfigData: wifi_ap: bool = False # WiFi AP mode configured ethernet: bool = False # Ethernet component active bluetooth: bool = False # any BLE component active - ble_42: bool = False # BLE 4.2 features needed software_coexistence: bool = False # WiFi/BT software coexistence requested # esp32 advanced enable_lwip_dhcp_server option (True/False/None=unset) enable_lwip_dhcp_server: bool | None = None @@ -647,6 +730,9 @@ def request_wifi(ap: bool = False) -> None: net.wifi = True if ap: net.wifi_ap = True + include_builtin_idf_component("esp_wifi") + # wifi_component.cpp includes esp_eap_client.h/esp_wpa2.h + include_builtin_idf_component("wpa_supplicant") def request_ethernet() -> None: @@ -654,17 +740,18 @@ def request_ethernet() -> None: _network_sdkconfig().ethernet = True -def request_bluetooth(ble_42: bool = False) -> None: - """Request the Bluetooth controller. Pass ble_42=True for 4.2 features.""" +def request_bluetooth() -> None: + """Request the Bluetooth controller.""" net = _network_sdkconfig() net.bluetooth = True - if ble_42: - net.ble_42 = True + include_builtin_idf_component("bt") def request_software_coexistence() -> None: """Request WiFi/BT software coexistence (only valid alongside WiFi).""" _network_sdkconfig().software_coexistence = True + # Callers include esp_coexist.h directly. + include_builtin_idf_component("esp_coex") def add_idf_component( @@ -727,6 +814,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. @@ -745,6 +843,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): @@ -814,14 +916,15 @@ 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, 10), - "latest": cv.Version(3, 3, 10), - "dev": cv.Version(3, 3, 10), + "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"), @@ -845,6 +948,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = { # 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), @@ -879,7 +983,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version(5, 5, 5): cv.Version(55, 3, 39), + 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), @@ -900,8 +1004,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 39), - "latest": cv.Version(55, 3, 39), + "recommended": cv.Version(55, 3, 311), + "latest": cv.Version(55, 3, 311), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } @@ -1028,17 +1132,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: return config -def _validate_toolchain(value) -> Toolchain: - return Toolchain(cv.one_of(*(t.value for t in Toolchain), lower=True)(value)) - - -def _resolve_toolchain(value: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - # Runs before _detect_variant so downstream validators can rely on - # CORE.toolchain instead of re-resolving it from the config dict. - if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) - return 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: ConfigType) -> ConfigType: @@ -1058,6 +1156,26 @@ 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) @@ -1070,6 +1188,8 @@ def _detect_variant(value): # 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: @@ -1080,22 +1200,8 @@ def _detect_variant(value): ) 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]: @@ -1105,6 +1211,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, " @@ -1116,6 +1230,9 @@ 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 @@ -1163,11 +1280,99 @@ def _ota_downgrade_protection_errors( 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_SIGNING_SCHEME, default="rsa3072"): cv.one_of( + 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 ), } @@ -1200,9 +1405,15 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: 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( @@ -1210,7 +1421,35 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: f"'{CONF_VERIFICATION_KEY}', not both.", path=[CONF_VERIFICATION_KEY], ) - if scheme == "ecdsa_v1": + 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}' " @@ -1231,12 +1470,19 @@ def _validate_signed_ota_keys(config: ConfigType) -> ConfigType: return config -def final_validate(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] @@ -1290,22 +1536,8 @@ 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}: + if config[CONF_VARIANT] not in PSRAM_XIP_VARIANTS: errs.append( cv.Invalid( f"'{CONF_EXECUTE_FROM_PSRAM}' is not available on this esp32 variant", @@ -1359,7 +1591,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 " @@ -1372,7 +1607,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]} " @@ -1383,7 +1620,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 " @@ -1393,7 +1630,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 " @@ -1404,8 +1645,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) @@ -1470,8 +1717,6 @@ def final_validate(config): if errs: raise cv.MultipleInvalid(errs) - return config - CONF_SDKCONFIG_OPTIONS = "sdkconfig_options" CONF_ENABLE_LWIP_DHCP_SERVER = "enable_lwip_dhcp_server" @@ -1494,6 +1739,8 @@ CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" +CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" +CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" @@ -1508,6 +1755,8 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" +KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" +KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" @@ -1541,6 +1790,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. @@ -1550,6 +1809,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 @@ -1581,6 +1841,30 @@ def require_mbedtls_pkcs7() -> None: CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True +def require_mbedtls_tls_server() -> None: + """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + + Call this from components that accept TLS connections (OpenThread's DTLS + commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from + being selected. + """ + CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + + +def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: + """Mark TLS features disabled by ``disable_mbedtls_tls_extras`` as required. + + ``options`` names the entries of ``MBEDTLS_TLS_EXTRA_OPTIONS`` to keep; + omit it to keep all of them. Call this from components that need AES-CCM, + deterministic ECDSA signing, static RSA/ECDH key exchange, TLS + renegotiation or session tickets, or that run a TLS client against + servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied + sdkconfig_options value is never overridden either. + """ + required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + + def require_mbedtls_sha512() -> None: """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. @@ -1738,6 +2022,8 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, @@ -1957,17 +2243,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) @@ -2025,6 +2314,94 @@ async def _set_libc_picolibc_newlib_compat() -> None: ) +@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) + + +# TLS features an HTTPS/MQTT client talking to a modern server never +# negotiates. Static RSA and static ECDH key exchange have no forward secrecy +# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables +# session tickets, AES-CCM ciphersuites are not offered by web servers, and +# deterministic ECDSA only matters when signing with a private key. Together +# they cost ~10 KB of flash whenever TLS is linked (http_request, mqtt). +# wpa_supplicant's EAP client is a second TLS client that talks to RADIUS +# servers ESPHome cannot vet, and a failed EAP handshake leaves the device +# off the network, so the wifi component re-enables all of these when eap is +# configured. +# The EC public key parsing extras stay enabled: they decide whether a peer +# certificate with a compressed point or explicit curve parameters parses, +# which no component can know ahead of time. +MBEDTLS_TLS_EXTRA_OPTIONS = ( + "CONFIG_MBEDTLS_KEY_EXCHANGE_RSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA", + "CONFIG_MBEDTLS_SSL_RENEGOTIATION", + "CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_CCM_C", + "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC", +) + +# Members of the mbedTLS "TLS Protocol Role" Kconfig choice. Setting one +# member is only valid when the user has not already chosen another. +MBEDTLS_TLS_ROLE_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_DISABLED", +) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_tls_sdkconfig( + disable_tls_server: bool, disable_tls_extras: bool +) -> None: + """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. + + Runs at FINAL priority so every require_mbedtls_tls_server() and + require_mbedtls_tls_extras() call has happened. Only the server-side + handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS + connections, but OpenThread's DTLS commissioner does. A user-supplied + sdkconfig_options value always wins; for the TLS role choice, any member + the user set leaves the whole choice alone so the pair cannot conflict. + """ + data = CORE.data[KEY_ESP32] + sdkconfig = data[KEY_SDKCONFIG_OPTIONS] + if ( + disable_tls_server + and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) + and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + ): + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) + add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) + if disable_tls_extras: + required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + for option in MBEDTLS_TLS_EXTRA_OPTIONS: + if option not in required: + set_idf_sdkconfig_default(option, False) + + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. @@ -2036,37 +2413,33 @@ async def _reconcile_network_sdkconfig() -> None: always takes precedence. """ net = CORE.data[KEY_ESP32].get(KEY_NETWORK_SDKCONFIG, NetworkSdkconfigData()) - opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] is_arduino = CORE.using_arduino - def set_opt(name: str, value: SdkconfigValueType) -> None: - # User sdkconfig_options (applied during to_code) win. - if name not in opts: - add_idf_sdkconfig_option(name, value) - - # Bluetooth: only ever enable when requested. The IDF default is off and - # nothing sets these False today, so never write False here. + # 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_opt("CONFIG_BT_ENABLED", True) - if net.ble_42: - set_opt("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) + 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. + # esp_wifi is excluded by default on IDF, so this only matters for Arduino + # or when bt pulls it back. wifi_disabled = net.ethernet and not net.wifi if wifi_disabled: - set_opt("CONFIG_ESP_WIFI_ENABLED", False) + 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_opt("CONFIG_SW_COEXIST_ENABLE", True) + set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True) elif wifi_disabled: - set_opt("CONFIG_SW_COEXIST_ENABLE", False) + 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_opt("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + 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 @@ -2077,7 +2450,7 @@ async def _reconcile_network_sdkconfig() -> None: if ( wifi_wants_dhcps_off or dhcp_server_disabled_by_option ) and not arduino_eth_exclusion: - set_opt("CONFIG_LWIP_DHCPS", False) + set_idf_sdkconfig_default("CONFIG_LWIP_DHCPS", False) @coroutine_with_priority(CoroPriority.FINAL) @@ -2092,6 +2465,57 @@ 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. @@ -2182,6 +2606,8 @@ async def to_code(config): 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") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) @@ -2240,6 +2666,17 @@ async def to_code(config): else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # esp_http_client calls vasprintf, which on the ESP32-C6 is the only + # reference to newlib's full printf engine (~20 KB: _svfprintf_r, + # _dtoa_r and their helpers); every other caller resolves to the + # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed + # because libsrc.a is scanned before the IDF libraries that + # reference the symbol, so the stub would otherwise never be pulled + # from the archive. + if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: + cg.add_define("USE_ESP32_VASPRINTF_STUB") + cg.add_build_flag("-Wl,--wrap=vasprintf") + cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -2274,21 +2711,11 @@ 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( @@ -2303,15 +2730,14 @@ async def to_code(config): 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, @@ -2419,13 +2845,7 @@ async def to_code(config): _configure_lwip_max_sockets(conf) if advanced[CONF_EXECUTE_FROM_PSRAM]: - if variant == VARIANT_ESP32S3: - add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) - add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) - elif variant == VARIANT_ESP32P4: - add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) - else: - raise ValueError("Unhandled ESP32 variant") + add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) # Apply LWIP core locking for better socket performance # This is already enabled by default in Arduino framework, where it provides @@ -2445,47 +2865,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: @@ -2546,9 +2925,70 @@ async def to_code(config): # 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) @@ -2659,6 +3099,26 @@ async def to_code(config): # 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 priority: runs after every require_mbedtls_tls_*() call + CORE.add_job( + _reconcile_mbedtls_tls_sdkconfig, + advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], + advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], + ) + + # 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 if advanced[CONF_DISABLE_REGI2C_IN_IRAM]: @@ -2674,19 +3134,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) - # Kconfig range is [1,10]; 0 gets clamped to the default. - add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1) - 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 @@ -2915,7 +3368,13 @@ def _write_sdkconfig(): if write_file_if_changed(internal_path, contents): # internal changed, update real one write_file_if_changed(sdk_path, contents) - clean_build(clear_pio_cache=False) + if not CORE.using_toolchain_esp_idf: + # PIO's dependency tracking under-declares sdkconfig inputs + # (ldgen, linker scripts); without a clean the image can be + # unbootable (esphome#15336). The esp-idf toolchain tracks + # sdkconfig via IDF's cmake and has_outdated_files(), so a + # reconfigure suffices there; everything else fails safe. + clean_build(clear_pio_cache=False) def _write_idf_component_yml(): @@ -3047,17 +3506,45 @@ 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): @@ -3108,9 +3595,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+)\)$" ) @@ -3128,9 +3616,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) @@ -3153,3 +3642,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/const.py b/esphome/components/esp32/const.py index 83fcfd233e7..a0c9809c50c 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -1,9 +1,15 @@ 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 KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import + +# Back compat for external components only; in-tree callers import it +# from esphome.espidf directly. +from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import + 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 +20,8 @@ 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" @@ -63,4 +69,5 @@ VARIANT_FRIENDLY = { 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 098a59937a1..a6916fe7394 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "preferences.h" #include #include @@ -29,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 a7de48a6ee9..6f65243aaa0 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 @@ -122,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; @@ -132,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"); @@ -151,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; @@ -185,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. @@ -240,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) @@ -320,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 @@ -332,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 @@ -349,18 +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); -#else - (void) pos; // There is no second-core append on single-core targets, so pos would otherwise be unread. -#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 @@ -370,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); @@ -382,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; @@ -391,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); } @@ -413,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 4b53d3a1721..74665f3126a 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.py b/esphome/components/esp32/gpio.py index 321dd3d498f..98aac209ec4 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -257,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}"))) diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index d49240723b7..7ccb7cdb903 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -5,11 +5,14 @@ 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): +# Per the ESP32-S31 IDF DOCS and datasheet: +# https://docs.espressif.com/projects/esp-idf/en/v6.1/esp32s31/api-reference/peripherals/gpio.html # 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} +_ESP32S31_SPI_FLASH_PINS: set[int] = {26, 27, 28, 30, 31, 32} +_ESP32S31_INVALID_PINS: set[int] = {29, 41} +# GPIO60/GPIO61 set the boot mode; GPIO37 selects the JTAG signal source; +# GPIO36 sets the VDD_SPI voltage. +_ESP32S31_STRAPPING_PINS: set[int] = {36, 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} @@ -19,6 +22,8 @@ _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_INVALID_PINS: + raise cv.Invalid(f"GPIO{value} does not exist on ESP32-S31.") 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." @@ -33,6 +38,10 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if num < 0 or num > 61: raise cv.Invalid(f"Invalid pin number: {num} (must be 0-61)") + # Checked here as well so ignore_pin_validation_error cannot bypass it; + # these pins are not bonded and can never work + if num in _ESP32S31_INVALID_PINS: + raise cv.Invalid(f"GPIO{num} does not exist on ESP32-S31.") if is_input: # All ESP32 pins support input mode pass diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index afcec8bfc70..91b4241211d 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/preferences.cpp b/esphome/components/esp32/preferences.cpp index dc2b40455cc..f3d5844cd7a 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -176,12 +176,21 @@ ESPPreferenceObject ESP32Preferences::make_preference(size_t length, uint32_t ty } s_open_err = ESP_OK; } - auto *pref = new ESP32PreferenceBackend(); // NOLINT(cppcoreguidelines-owning-memory) - pref->nvs_handle = this->nvs_handle; - pref->key = type; - pref->in_flash = true; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return ESPPreferenceObject(new ESP32PreferenceBackend(this->make_backend_(type))); +} - return ESPPreferenceObject(pref); +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 diff --git a/esphome/components/esp32/preferences.h b/esphome/components/esp32/preferences.h index 864d22312b9..91258439587 100644 --- a/esphome/components/esp32/preferences.h +++ b/esphome/components/esp32/preferences.h @@ -23,12 +23,15 @@ class ESP32Preferences final : public PreferencesMixin { 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 diff --git a/esphome/components/esp32/vasprintf_stubs.cpp b/esphome/components/esp32/vasprintf_stubs.cpp new file mode 100644 index 00000000000..308a58ebda7 --- /dev/null +++ b/esphome/components/esp32/vasprintf_stubs.cpp @@ -0,0 +1,53 @@ +/* + * Linker wrap stub for vasprintf() on variants whose ROM exports a + * full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only). + * + * On those chips every snprintf/vsnprintf call in the image resolves to + * the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their + * helpers, ~20 KB) is not linked at all until something references a + * printf-family function the ROM lacks. esp_http_client does exactly that + * through vasprintf() in its header and auth helpers, so adding + * http_request to a build costs the whole engine on top of the HTTP and + * TLS code itself. + * + * This stub reimplements vasprintf() on top of the ROM vsnprintf(), which + * keeps the engine out of the image. It is only compiled in when codegen + * defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM + * linker script and on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB) + +#include +#include +#include + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) { + va_list ap_copy; + va_copy(ap_copy, ap); + int len = vsnprintf(nullptr, 0, fmt, ap_copy); + va_end(ap_copy); + if (len < 0) { + return len; + } + // vasprintf's contract is a malloc'd buffer the caller releases with free() + char *buf = static_cast(malloc(static_cast(len) + 1)); // NOLINT(cppcoreguidelines-no-malloc) + if (buf == nullptr) { + return -1; + } + vsnprintf(buf, static_cast(len) + 1, fmt, ap); + *strp = buf; + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c8613963b9d..7e97111686b 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -1,5 +1,4 @@ from collections.abc import Callable, MutableMapping -from dataclasses import dataclass from enum import Enum import logging from typing import Any @@ -23,6 +22,7 @@ from esphome.components.esp32 import ( 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, @@ -32,7 +32,8 @@ 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 @@ -134,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( @@ -171,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", @@ -185,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", @@ -199,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", @@ -213,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", @@ -227,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", @@ -381,7 +385,7 @@ def _validate_key_sizes(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) -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") @@ -441,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() @@ -512,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])) @@ -576,7 +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) - request_bluetooth(ble_42=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 @@ -633,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 fb75e8837f2..81fa328c160 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -83,38 +83,60 @@ void ESP32BLE::setup() { } } -void ESP32BLE::enable() { - if (this->state_ != BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_ENABLE; -} - -void ESP32BLE::disable() { - if (this->state_ == BLE_COMPONENT_STATE_DISABLED) - return; - - this->state_ = BLE_COMPONENT_STATE_DISABLE; +// Queue the transition for loop(). A pending transition the other way is +// cancelled instead, since nothing was torn down or brought up yet; any other +// state is already there or on its way. +void ESP32BLE::request_state_(bool enable) { + if (enable) { + if (this->state_ == BLE_COMPONENT_STATE_DISABLED) { + this->state_ = BLE_COMPONENT_STATE_ENABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_DISABLE) { + this->state_ = BLE_COMPONENT_STATE_ACTIVE; + } + } else { + if (this->state_ == BLE_COMPONENT_STATE_ACTIVE) { + this->state_ = BLE_COMPONENT_STATE_DISABLE; + } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { + this->state_ = BLE_COMPONENT_STATE_DISABLED; + } + } } #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_start() { this->advertising_init_(); - if (!this->is_active()) + this->advertising_ref_count_++; + this->advertising_refresh(); +} + +void ESP32BLE::advertising_stop() { + if (this->advertising_ref_count_ == 0) return; - this->advertising_->start(); + this->advertising_ref_count_--; + this->advertising_refresh(); +} + +void ESP32BLE::advertising_refresh() { + if (this->advertising_ == nullptr || !this->is_active()) + return; + // Advertise while any component still needs it, otherwise stop + if (this->advertising_ref_count_ == 0) { + this->advertising_->stop(); + } else { + this->advertising_->start(); + } } void ESP32BLE::advertising_set_service_data(const std::vector &data) { this->advertising_init_(); this->advertising_->set_service_data(data); - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_set_manufacturer_data(const std::vector &data) { this->advertising_init_(); this->advertising_->set_manufacturer_data(data); - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_set_service_data_and_name(std::span data, bool include_name) { @@ -136,7 +158,7 @@ void ESP32BLE::advertising_set_service_data_and_name(std::span da this->advertising_->set_service_data(data); } - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_register_raw_advertisement_callback(std::function &&callback) { @@ -147,13 +169,13 @@ void ESP32BLE::advertising_register_raw_advertisement_callback(std::functionadvertising_init_(); this->advertising_->add_service_uuid(uuid); - this->advertising_start(); + this->advertising_refresh(); } void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { this->advertising_init_(); this->advertising_->remove_service_uuid(uuid); - this->advertising_start(); + this->advertising_refresh(); } #endif @@ -563,7 +585,11 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { this->mark_failed(); return; } - this->state_ = BLE_COMPONENT_STATE_DISABLED; + this->drain_ble_events_(); + // A status callback may have asked for BLE back; the stack is down now, so + // that request becomes a bring-up. + this->state_ = + this->state_ == BLE_COMPONENT_STATE_ACTIVE ? BLE_COMPONENT_STATE_ENABLE : BLE_COMPONENT_STATE_DISABLED; } else if (this->state_ == BLE_COMPONENT_STATE_ENABLE) { ESP_LOGD(TAG, "Enabling"); this->state_ = BLE_COMPONENT_STATE_OFF; @@ -575,6 +601,10 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { } this->state_ = BLE_COMPONENT_STATE_ACTIVE; +#ifdef USE_ESP32_BLE_ADVERTISING + // Requests made before the stack was up (or before it was re-enabled) take effect now + this->advertising_refresh(); +#endif } } @@ -643,11 +673,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: @@ -674,11 +726,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: @@ -701,7 +765,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 c85ddfc983f..fd4fb15ff69 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -102,17 +102,29 @@ class ESP32BLE final : public Component { } uint32_t get_advertising_cycle_time() const { return this->advertising_cycle_time_; } - void enable(); - void disable(); + void enable() { this->request_state_(true); } + void disable() { this->request_state_(false); } ESPHOME_ALWAYS_INLINE bool is_active() { return this->state_ == BLE_COMPONENT_STATE_ACTIVE; } 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; } #ifdef USE_ESP32_BLE_ADVERTISING + /** Request advertising on behalf of a component. + * + * Requests are reference counted: advertising runs until every component that called + * advertising_start() has released it again with advertising_stop(). Each component must + * pair its calls, so nothing advertises until something actually asks for it. + */ void advertising_start(); + /// Release a request made with advertising_start(); advertising stops at the last release. + void advertising_stop(); + /// Apply the current payload and request count: advertise while requested, otherwise stop. + void advertising_refresh(); void advertising_set_service_data(const std::vector &data); void advertising_set_manufacturer_data(const std::vector &data); void advertising_set_appearance(uint16_t appearance) { this->appearance_ = appearance; } @@ -164,6 +176,15 @@ class ESP32BLE final : public Component { bool ble_setup_(); bool ble_dismantle_(); + void request_state_(bool enable); + // Drop what the old stack queued; the next stack reuses the same interface ids. + void drain_ble_events_() { + BLEEvent *ble_event; + while ((ble_event = this->ble_events_.pop()) != nullptr) { + this->ble_event_pool_.release(ble_event); + } + this->ble_events_.get_and_reset_dropped_count(); + } bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); @@ -224,6 +245,9 @@ class ESP32BLE final : public Component { // 1-byte aligned members (grouped together to minimize padding) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte +#ifdef USE_ESP32_BLE_ADVERTISING + uint8_t advertising_ref_count_{0}; // 1 byte, number of components requesting advertising +#endif #ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS optional auth_req_mode_; diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 7a59cce19b4..e9c44284e43 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -5,6 +5,7 @@ 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,4 +87,4 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") - request_bluetooth(ble_42=True) + request_bluetooth() diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 9f1723430b4..ab728f9f6fc 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -67,6 +67,8 @@ void ESP32BLEBeacon::setup() { this->on_advertise_(); } }); + // A beacon always needs the device to advertise, and never releases the request + global_ble->advertising_start(); } void ESP32BLEBeacon::on_advertise_() { diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 3fb9632e9a8..88454f7bdbf 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, @@ -48,7 +42,7 @@ void BLEClientBase::set_state(espbt::ClientState st) { void BLEClientBase::loop() { if (!esp32_ble::global_ble->is_active()) { - this->set_state(espbt::ClientState::INIT); + // ble_before_disabled_event_handler() resets the client. return; } if (this->state() == espbt::ClientState::INIT) { @@ -78,6 +72,21 @@ void BLEClientBase::loop() { float BLEClientBase::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +void BLEClientBase::ble_before_disabled_event_handler() { + auto st = this->state(); + if (st != espbt::ClientState::IDLE && st != espbt::ClientState::INIT) { + // No CLOSE_EVT will come: free the services and settle the link. + this->release_services(); + this->set_idle_(); + this->on_disconnect_complete(ESP_GATT_CONN_TERMINATE_LOCAL_HOST); + } + // The interface belongs to the torn-down stack. + this->gattc_if_ = ESP_GATT_IF_NONE; + this->set_state(espbt::ClientState::INIT); + // An idle client runs no loop; the INIT branch must run to register again. + this->enable_loop(); +} + void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" @@ -99,6 +108,10 @@ bool BLEClientBase::parse_device(const espbt::ESPBTDevice &device) { return false; if (this->state() != espbt::ClientState::IDLE) return false; + // Not registered on this stack yet; promoting now would stop the scan for a + // connect that connect() rejects anyway. + if (this->gattc_if_ == ESP_GATT_IF_NONE) + return false; this->log_event_("Found device"); if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG) @@ -123,8 +136,20 @@ void BLEClientBase::connect() { this->connection_index_, this->address_str_); return; } + if (this->gattc_if_ == ESP_GATT_IF_NONE) { + // Bluedroid drops an open on an unknown interface without any event. + this->log_warning_("Connect rejected, GATT app not registered"); + // INIT stays so loop() still registers; only a promoted client goes back. + if (this->state() == espbt::ClientState::DISCOVERED) { + this->set_state(espbt::ClientState::IDLE); + } + return; + } 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 @@ -200,10 +225,29 @@ void BLEClientBase::release_services() { this->services_.clear(); #endif #ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH - esp_ble_gattc_cache_clean(this->remote_bda_); + // Only the cache clean makes the stack's database unsafe to walk. + this->services_released_ = true; + // A stack on its way down frees its own cache. + if (esp32_ble::global_ble->is_active()) { + 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); } @@ -498,12 +542,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 0291a4b9934..fbd405156ae 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -41,9 +41,16 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { void connect() override; esp_err_t pair(); void disconnect() override; + void ble_before_disabled_event_handler() override; 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 +93,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_; } @@ -106,7 +115,7 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { #endif // Group 3: 4-byte types - int gattc_if_; + int gattc_if_{ESP_GATT_IF_NONE}; esp_gatt_status_t status_{ESP_GATT_OK}; // Group 4: Arrays @@ -125,9 +134,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 by release_services() on RAM-cache builds; the stack's GATT database must not be walked after it + bool services_released_{false}; + // 8 bytes used, no padding void log_event_(const char *name); void log_gattc_lifecycle_event_(const char *name); @@ -141,10 +156,11 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { 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. + /// set_idle_()): CLOSE_EVT, the DISCONNECTING safety timeout, or the BLE stack going down. /// 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. + /// override this to release that state. `reason` is the controller reason code, + /// ESP_GATT_CONN_TIMEOUT for the safety timeout, or ESP_GATT_CONN_TERMINATE_LOCAL_HOST + /// for the stack going down. 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_() { diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index ea2a9667d72..118ae06e420 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,6 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -41,7 +42,6 @@ CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" CONF_INDICATE = "indicate" -CONF_MANUFACTURER = "manufacturer" CONF_MANUFACTURER_DATA = "manufacturer_data" CONF_MAX_CLIENTS = "max_clients" CONF_ON_WRITE = "on_write" @@ -307,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: @@ -355,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): @@ -597,6 +596,18 @@ async def to_code(config): cg.add(var.set_parent(parent)) cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) + # Only advertise for the server itself when the configuration gives clients something to + # find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays + # silent until that service asks for advertising. + cg.add( + var.set_advertising_required( + CONF_MANUFACTURER_DATA in config + or any( + not uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID) + for service_config in config[CONF_SERVICES] + ) + ) + ) if CONF_MANUFACTURER_DATA in config: cg.add(var.set_manufacturer_data(config[CONF_MANUFACTURER_DATA])) for service_config in config[CONF_SERVICES]: diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 2dea1666bbe..45679b9b982 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -81,6 +81,7 @@ void BLEServer::loop() { if (this->device_information_service_->is_running()) { this->state_ = RUNNING; this->restart_advertising_(); + this->request_advertising_(); ESP_LOGD(TAG, "BLE server setup successfully"); } else if (this->device_information_service_->is_created()) { this->device_information_service_->start(); @@ -98,6 +99,20 @@ void BLEServer::restart_advertising_() { } } +void BLEServer::request_advertising_() { + if (!this->advertising_required_ || this->advertising_requested_) + return; + this->advertising_requested_ = true; + this->parent_->advertising_start(); +} + +void BLEServer::release_advertising_() { + if (!this->advertising_requested_) + return; + this->advertising_requested_ = false; + this->parent_->advertising_stop(); +} + BLEService *BLEServer::create_service(ESPBTUUID uuid, bool advertise, uint16_t num_handles) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char uuid_buf[esp32_ble::UUID_STR_LEN]; @@ -170,7 +185,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga this->add_client_(param->connect.conn_id); // Resume advertising so additional clients can discover and connect if (this->client_count_ < this->max_clients_) { - this->parent_->advertising_start(); + this->parent_->advertising_refresh(); } this->dispatch_callbacks_(CallbackType::ON_CONNECT, param->connect.conn_id); break; @@ -178,7 +193,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga case ESP_GATTS_DISCONNECT_EVT: { ESP_LOGD(TAG, "BLE Client disconnected"); this->remove_client_(param->disconnect.conn_id); - this->parent_->advertising_start(); + this->parent_->advertising_refresh(); this->dispatch_callbacks_(CallbackType::ON_DISCONNECT, param->disconnect.conn_id); break; } @@ -226,6 +241,8 @@ void BLEServer::remove_client_(uint16_t conn_id) { } void BLEServer::ble_before_disabled_event_handler() { + // Advertising is re-requested once the server is running again after BLE is re-enabled + this->release_advertising_(); // Delete all clients this->client_count_ = 0; // Delete all services diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index fdd92812cd4..7869c73cc53 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -38,6 +38,13 @@ class BLEServer final : public Component, public Parented { this->restart_advertising_(); } + /** Whether this server needs the device to advertise so clients can find and connect to it. + * + * False for a server that only hosts services created at runtime (e.g. esp32_improv), which + * request advertising themselves for as long as they need it. + */ + void set_advertising_required(bool required) { this->advertising_required_ = required; } + void set_max_clients(uint8_t max_clients) { this->max_clients_ = max_clients; } uint8_t get_max_clients() const { return this->max_clients_; } @@ -82,6 +89,8 @@ class BLEServer final : public Component, public Parented { }; void restart_advertising_(); + void request_advertising_(); + void release_advertising_(); int8_t find_client_index_(uint16_t conn_id) const; void add_client_(uint16_t conn_id); @@ -93,6 +102,8 @@ class BLEServer final : public Component, public Parented { std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; bool registered_{false}; + bool advertising_required_{true}; + bool advertising_requested_{false}; uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{}; uint8_t client_count_{0}; diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 2febb16cf46..906144e5fde 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,13 +1,17 @@ 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 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, ) @@ -35,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 +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__) @@ -57,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]: @@ -74,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: @@ -126,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( @@ -154,6 +133,136 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: return config +# 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) + + +@dataclass +class TrackerData: + """Per-run validation state, namespaced under DOMAIN in CORE.data.""" + + scan_window_defaulted: bool = False + connection_window_injected: bool = False + + +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 @@ -169,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( @@ -224,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, ) @@ -236,7 +330,7 @@ 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) @@ -244,6 +338,9 @@ async def to_code(config): # 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) @@ -256,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])) @@ -269,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]))) @@ -292,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]))) @@ -305,7 +419,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_SCAN_END, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) @@ -333,33 +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. - neutral_listener_count = ble_device_base.get_listener_count() - if neutral_listener_count > 0: + 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) - # StaticVector sizing for the neutral (BLEHub) listener list — same - # pattern as the esp32-path registration counts below. - cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", neutral_listener_count) 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( { @@ -376,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_) @@ -401,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 @@ -412,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 @@ -420,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: @@ -448,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 b653325f56e..541b63b2fdb 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -3,6 +3,8 @@ #include "esphome/core/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include + #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 141aa6729de..e25b6f59fa0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -36,27 +36,6 @@ static const char *const TAG = "esp32_ble_tracker"; 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() { @@ -76,12 +55,18 @@ 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 for (auto *client : this->clients_) { client->disconnect(); } +#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE + // The OTA transfer blocks the main loop, so the revert in loop() cannot run. No + // active-connection gate here: every client was just told to disconnect. + this->update_coex_preference_(false); +#endif #endif } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { this->scan_continuous_before_ota_ = false; @@ -94,11 +79,11 @@ void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, u void ESP32BLETracker::loop() { if (!this->parent_->is_active()) { - this->ble_was_disabled_ = true; return; - } else if (this->ble_was_disabled_) { + } + if (this->ble_was_disabled_) { this->ble_was_disabled_ = false; - // If the BLE stack was disabled, we need to start the scan again. + // First start after boot or after the stack came back. if (this->scan_continuous_) { this->start_scan(); } @@ -142,6 +127,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. @@ -164,6 +152,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: @@ -211,20 +212,47 @@ 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::ble_before_disabled_event_handler() { + // Tell the controller to stop; a scan still starting has nothing to stop yet. + if (this->scanner_state_ == ScannerState::RUNNING || this->scanner_state_ == ScannerState::FAILED) { + this->stop_scan_(); + } +#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT + for (auto *client : this->clients_) { + client->ble_before_disabled_event_handler(); + } + this->skip_next_scan_end_ = false; +#endif + // The stop above never completes (stack torn down, events dropped); settle + // here so start_scan_() sees IDLE once the stack is back. + if (this->scanner_state_ != ScannerState::IDLE) { + this->cleanup_scan_state_(true); + } + // A failure latched by the old stack must not be handled against the next. + this->scan_start_failed_ = ESP_BT_STATUS_SUCCESS; + this->scan_set_param_failed_ = ESP_BT_STATUS_SUCCESS; + this->ble_was_disabled_ = true; +} -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; @@ -232,8 +260,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) { @@ -247,24 +276,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 ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT - for (auto *listener : this->neutral_listeners_) - listener->on_scan_end(); -#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 @@ -292,7 +326,9 @@ 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 } @@ -304,48 +340,11 @@ void ESP32BLETracker::register_listener(ble_device_base::ESPBTDeviceListener *li #endif } -void ESP32BLETracker::get_adapter_mac(uint8_t out[6]) { - get_mac_address_raw(out); // WiFi base MAC, MSB-first - // BT MAC = base MAC + 2 on the last octet only, wrapping without carry — - // exactly ESP-IDF's esp_read_mac(ESP_MAC_BT): mac[5] += MAC_ADDR_UNIVERSE_BT_OFFSET. - out[5] += 2; -} - 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_BLE_DEVICE_BASE_LISTENER_COUNT - // Neutral (BLEHub) listeners are parsed-advertisement consumers and are not in - // listeners_; without this, any later esp32-path registration (e.g. the proxy's - // GATT clients) would recompute the flags and silently drop parsed dispatch. - if (!this->neutral_listeners_.empty()) - this->parse_advertisements_ = true; -#endif -#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 } @@ -443,9 +442,11 @@ 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); } +#endif } void ESP32BLETracker::dump_config() { @@ -458,6 +459,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", @@ -471,42 +477,9 @@ 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); } // resolve_irk() is provided by ble_device_base (portable software AES). @@ -514,24 +487,23 @@ void ESP32BLETracker::print_bt_device_info(const ESPBTDevice &device) { #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; @@ -566,11 +538,23 @@ 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(); @@ -579,8 +563,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) { for (auto *listener : this->neutral_listeners_) listener->on_scan_end(); #endif - - this->set_scanner_state_(ScannerState::IDLE); } void ESP32BLETracker::handle_scanner_failure_() { @@ -618,6 +600,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 c20962eb258..1a424a4a8e8 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -8,7 +8,6 @@ #include #include #include -#include #ifdef USE_ESP32 @@ -19,6 +18,7 @@ #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" @@ -35,11 +35,6 @@ using namespace esp32_ble; using adv_data_t = ble_device_base::adv_data_t; -enum AdvertisementParserType { - PARSED_ADVERTISEMENTS, - RAW_ADVERTISEMENTS, -}; - #ifdef USE_ESP32_BLE_UUID using ServiceData = ble_device_base::ServiceData; #endif @@ -63,10 +58,6 @@ class ESPBTDeviceListener : public ble_device_base::ESPBTDeviceListener { // 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: @@ -89,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. /// @@ -159,11 +104,18 @@ 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; virtual void connect() = 0; virtual void disconnect() = 0; + /// Called right before the BLE stack is dismantled. Nothing in flight will + /// complete, and the GATT app must register again once the stack is back. + virtual void ble_before_disabled_event_handler() {} bool disconnect_pending() const { return this->want_disconnect_; } void cancel_pending_disconnect() { this->want_disconnect_ = false; } @@ -212,7 +164,6 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker final : public Component, - public ble_device_base::BLEHub, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -221,6 +172,9 @@ class ESP32BLETracker final : public Component, 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; } @@ -235,19 +189,29 @@ class ESP32BLETracker final : public Component, // 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) override; - void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback cb) override { - this->raw_advertisement_callback_ = std::move(cb); + void register_listener(ble_device_base::ESPBTDeviceListener *listener); + void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) { + this->raw_advertisement_callback_ = callback; } - ble_device_base::HubCapabilities get_capabilities() const override { - return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true}; +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void set_scanner_state_callback(ble_device_base::ScannerStateCallback callback) { + this->scanner_state_callback_ = callback; } - void get_adapter_mac(uint8_t out[6]) override; - bool scan_running() override { return this->scanner_state_ == ScannerState::RUNNING; } - bool scan_active() override { return this->scan_active_; } +#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); @@ -265,14 +229,13 @@ class ESP32BLETracker final : 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. @@ -335,16 +298,18 @@ class ESP32BLETracker final : 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_{nullptr}; + 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) @@ -357,6 +322,15 @@ class ESP32BLETracker final : 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}; @@ -374,16 +348,20 @@ class ESP32BLETracker final : 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 { @@ -391,10 +369,10 @@ class ESP32BLETracker final : 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 c3b35a8279b..3c41d22903f 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") diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index da260ad7a1c..d54d5c69376 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 7527bbf7e4e..bee231d132d 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,11 @@ 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 +227,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_can/canbus.py b/esphome/components/esp32_can/canbus.py index 7245ba75133..2272459fb8f 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_dac/output.py b/esphome/components/esp32_dac/output.py index 7c63d7bd11c..c87a9e2a1d0 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 16e9d497821..21626e432b6 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -3,7 +3,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_USE_PSRAM +from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -16,8 +16,10 @@ 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"] @@ -33,9 +35,27 @@ 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" +# ESP-NOW-over-hosted shim (esp_now_hosted.cpp). esp-hosted proxies esp_wifi.h +# but not esp_now.h (espressif/esp-hosted-mcu#19), and esp_wifi_remote injects +# the esp_now.h header on the ESP32-P4 host with no implementation, leaving the +# esp_now_* symbols undefined at link. On a P4 host, esp_now_hosted.cpp DEFINES +# those symbols and forwards each call to the co-processor over esp-hosted's +# CustomRpc "peer data transfer" channel, so ESPHome's `espnow` component links +# and runs unchanged (proven on a Tab5, 2026-07-20). The .cpp is guarded to +# CONFIG_IDF_TARGET_ESP32P4 so it compiles to nothing on hosts with a native +# ESP-NOW stack. CustomRpc needs these two host-side Kconfig options. Host +# registers 3 handlers (RESP, RECV, SEND); the coprocessor registers 1 (REQ); +# we ask for 8 to leave room for other CustomRpc extensions alongside. +# +# The coprocessor must run the matching custom firmware (a parallel effort in +# esphome/esp-hosted-firmware). esp_now_hosted_rpc.h here is the canonical copy +# of the wire contract and MUST stay byte-identical to the copy that coprocessor +# firmware uses — the packed structs are the on-wire layout, so any divergence +# silently corrupts every ESP-NOW frame. +_MAX_CUSTOM_MSG_HANDLERS = 8 + # Shared fields for both transport modes BASE_SCHEMA = cv.Schema( { @@ -63,7 +83,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: @@ -97,7 +117,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) @@ -125,7 +145,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}", @@ -167,7 +202,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] @@ -215,7 +250,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" @@ -246,24 +281,37 @@ async def to_code(config): else: _configure_spi(config) + # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see + # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not + # gated on the `espnow` component being present: the shim is tiny and the + # esp_now_* symbols/CustomRpc calls it defines require these Kconfig options + # to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so + # coupling the two keeps the build consistent. When `espnow` is absent the + # symbols are simply unused and never register a callback at runtime. + if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: + add_define("USE_ESP_NOW_HOSTED") + # esp-hosted's CustomRpc ("peer data transfer") path — off by default. + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True + ) + esp32.add_idf_sdkconfig_option( + "CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS + ) + # 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 + # 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.9") - else: - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") - esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") - 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/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp new file mode 100644 index 00000000000..ad29b208feb --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -0,0 +1,467 @@ +/* + * esp_now_hosted — host-side shim implementing over esp-hosted + * CustomRpc, so ESPHome's `espnow` component can run on a radio-less host + * (e.g. the ESP32-P4) whose radio lives on an esp-hosted co-processor. + * + * A radio-less host has no native ESP-NOW. esp_wifi_remote INJECTS the full + * esp_now.h header (types + declarations) but ships NO implementation, so every + * esp_now_* symbol is an undefined reference at link time. This translation + * unit provides those definitions; each forwards to the co-processor over + * CustomRpc (see esphome/esp-hosted-firmware for the matching coprocessor + * handlers). No esp-hosted or esp_wifi_remote source is patched, and there is no + * duplicate-symbol clash because nothing else defines these symbols here. + * + * See esp_now_hosted_rpc.h for the wire protocol. + */ + +#include "sdkconfig.h" + +// Only build the shim on the radio-less host. On chips with a native ESP-NOW +// stack (S3, C6, …) the real symbols exist and this file must stay empty to +// avoid duplicate definitions. +#if defined(CONFIG_IDF_TARGET_ESP32P4) + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_idf_version.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include // injected declarations we are now DEFINING +#include // wifi_pkt_rx_ctrl_t, wifi_tx_info_t + +// esp_hosted_misc.h (host) ships WITHOUT an extern "C" guard, so including it +// from C++ would give its declarations C++ linkage and the real C symbols in +// libesp_hosted would go unresolved at link. Wrap it. (Verified vs +// esp_hosted 2.12.9.) +extern "C" { +#include "esp_hosted_misc.h" // esp_hosted_{send_custom_data,register_custom_callback} +} + +#include "esp_now_hosted_rpc.h" + +namespace { + +const char *const TAG = "esp_now_hosted"; + +// One outstanding request at a time. ESPHome drives esp_now_* from the main +// loop; the matching response and the async RECV/SEND events all arrive on the +// single esp-hosted RPC RX thread. Serializing requests keeps the shared +// response slot race-free; a sequence number stops a late/stale response from +// being mistaken for ours. +SemaphoreHandle_t g_req_mutex = nullptr; +SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands +bool g_setup_done = false; // set only after setup fully succeeds +uint8_t g_seq = 0; +volatile uint8_t g_expect_seq = 0; +volatile int32_t g_resp_status = 0; +uint8_t g_resp_ret[16]; +volatile uint16_t g_resp_ret_len = 0; + +// Written from the main loop (register/unregister/deinit), read from the +// esp-hosted RX thread (on_recv/on_send). volatile for the same reason the +// g_resp_* globals are: force the RX thread to observe an updated pointer +// (e.g. a nulling by esp_now_deinit) rather than a cached one. +volatile esp_now_recv_cb_t g_recv_cb = nullptr; +volatile esp_now_send_cb_t g_send_cb = nullptr; + +// Local mirror of the co-processor's peer table. ESPHome's espnow component +// calls esp_now_is_peer_exist() on the main loop for every received frame +// (twice) and every send; forwarding each as a blocking RPC round-trip stalls +// the loop. The shim is the only path that mutates the co-processor peer table +// (add/del/deinit all go through here), so this mirror is authoritative and +// esp_now_is_peer_exist() can answer from it with no round-trip. +// +// esp_now_* are public C symbols: any component or user lambda may call them, +// and although ESPHome's espnow touches peers only from the main loop today +// (its RX/TX callbacks merely enqueue), the shim cannot rely on that. A short +// spinlock keeps the mirror consistent from any task/core, matching native +// esp_now_*'s own internal thread-safety. The critical sections are a bounded +// (<=20-entry) scan, so they stay tiny. ESP_NOW_MAX_TOTAL_PEER_NUM is 20. +constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; +uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; +size_t g_peer_count = 0; +portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; + +// Caller must hold g_peer_lock. +int peer_cache_find_locked(const uint8_t *mac) { + for (size_t i = 0; i < g_peer_count; i++) { + if (memcmp(g_peer_cache[i], mac, 6) == 0) + return static_cast(i); + } + return -1; +} + +bool peer_cache_contains(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + const bool found = peer_cache_find_locked(mac) >= 0; + portEXIT_CRITICAL(&g_peer_lock); + return found; +} + +void peer_cache_add(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + if (peer_cache_find_locked(mac) < 0 && g_peer_count < ESP_NOW_HOSTED_MAX_PEERS) + memcpy(g_peer_cache[g_peer_count++], mac, 6); + portEXIT_CRITICAL(&g_peer_lock); +} + +void peer_cache_remove(const uint8_t *mac) { + portENTER_CRITICAL(&g_peer_lock); + const int idx = peer_cache_find_locked(mac); + if (idx >= 0) { + g_peer_count--; + if (static_cast(idx) != g_peer_count) // move the last entry into the gap + memcpy(g_peer_cache[idx], g_peer_cache[g_peer_count], 6); + } + portEXIT_CRITICAL(&g_peer_lock); +} + +void peer_cache_clear() { + portENTER_CRITICAL(&g_peer_lock); + g_peer_count = 0; + portEXIT_CRITICAL(&g_peer_lock); +} + +// ── CustomRpc event handlers (run on the esp-hosted RPC RX thread) ────────── +// Keep them short and non-blocking. In particular they MUST NOT call back into +// any esp_now_* shim function: that would try to take g_req_mutex / wait on the +// RX thread that delivers the response, and deadlock. + +void on_resp(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + if (len < sizeof(esp_now_hosted_resp_t)) { + ESP_LOGW(TAG, "RESP too short: %u bytes", static_cast(len)); + return; + } + const auto *r = reinterpret_cast(data); + if (r->seq != g_expect_seq) { // late response from a timed-out request (expected) + ESP_LOGV(TAG, "dropping stale RESP seq %u (want %u)", r->seq, g_expect_seq); + return; + } + g_resp_status = r->status; + uint16_t rl = r->ret_len; + if (rl > sizeof(g_resp_ret)) { + // Larger than any real opcode return — a likely wire-format drift signal. + ESP_LOGW(TAG, "RESP ret_len %u exceeds buffer, clamping (wire drift?)", rl); + rl = sizeof(g_resp_ret); + } + if (len >= sizeof(esp_now_hosted_resp_t) + rl) { + memcpy(g_resp_ret, r->ret, rl); + } else { + // Truncated frame: fail closed. Never hand the caller stale bytes left in + // g_resp_ret by a previous response, and don't let request() report a + // zeroed payload as success — override the status to an error. + ESP_LOGW(TAG, "RESP truncated: claims %u ret bytes, frame too short", rl); + rl = 0; + g_resp_status = ESP_ERR_INVALID_RESPONSE; + } + g_resp_ret_len = rl; + xSemaphoreGive(g_resp_sem); +} + +void on_recv(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + // Read the volatile pointer once: esp_now_unregister_recv_cb()/deinit() (via + // the espnow component's disable()) can null it on the main loop between the + // guard and the call, which would otherwise turn the call into a null-deref. + const esp_now_recv_cb_t cb = g_recv_cb; + if (cb == nullptr) + return; + if (len < sizeof(esp_now_hosted_recv_evt_t)) { + ESP_LOGW(TAG, "RECV too short: %u bytes", static_cast(len)); + return; + } + const auto *e = reinterpret_cast(data); + if (len < sizeof(esp_now_hosted_recv_evt_t) + e->data_len) { + ESP_LOGW(TAG, "RECV data_len %u exceeds frame", e->data_len); + return; + } + + // ESPHome dereferences info->rx_ctrl->{rssi,timestamp}; give it a real one. + wifi_pkt_rx_ctrl_t rx_ctrl; + memset(&rx_ctrl, 0, sizeof(rx_ctrl)); + rx_ctrl.rssi = e->rssi; + rx_ctrl.channel = e->channel; + rx_ctrl.timestamp = static_cast(esp_timer_get_time()); + + esp_now_recv_info_t info; + info.src_addr = const_cast(e->src_addr); + info.des_addr = const_cast(e->des_addr); + info.rx_ctrl = &rx_ctrl; + cb(&info, e->data, static_cast(e->data_len)); +} + +void on_send(uint32_t /*msg_id*/, const uint8_t *data, size_t len, void * /*ctx*/) { + // Read the volatile pointer once (see on_recv): disable()/deinit() can null it + // on the main loop concurrently with this RX-thread callback. + const esp_now_send_cb_t cb = g_send_cb; + if (cb == nullptr) + return; + if (len < sizeof(esp_now_hosted_send_evt_t)) { + ESP_LOGW(TAG, "SEND evt too short: %u bytes", static_cast(len)); + return; + } + const auto *e = reinterpret_cast(data); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // IDF >= 5.5: esp_now_send_cb_t takes esp_now_send_info_t (== wifi_tx_info_t), + // whose des_addr is a POINTER (not an inline array). Point it at the event's + // MAC (valid for this callback) — do NOT memcpy into it (that writes NULL and + // faults). ESPHome reads only info->des_addr. + esp_now_send_info_t si; + memset(&si, 0, sizeof(si)); + si.des_addr = const_cast(e->des_addr); + cb(&si, static_cast(e->status)); +#else + cb(e->des_addr, static_cast(e->status)); +#endif +} + +esp_err_t ensure_setup() { + // Gate on g_setup_done, not on g_req_mutex: a failure part-way through (a + // semaphore that did not allocate, a callback that did not register) must not + // leave a later call thinking setup completed. Semaphore creation is guarded + // so a retry after a partial failure does not leak the earlier handles. + if (g_setup_done) + return ESP_OK; + if (g_req_mutex == nullptr) + g_req_mutex = xSemaphoreCreateMutex(); + if (g_resp_sem == nullptr) + g_resp_sem = xSemaphoreCreateBinary(); + if (g_req_mutex == nullptr || g_resp_sem == nullptr) + return ESP_ERR_NO_MEM; + esp_err_t err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + return err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + return err; + if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + return err; + g_setup_done = true; + return ESP_OK; +} + +// Send one request envelope. With wait=true (default) block until the matching +// response (or timeout); with wait=false return as soon as the frame is handed +// to the transport (fire-and-forget, used by esp_now_send). +// +// `tail` is an optional second chunk written straight after `payload`. Callers +// with a fixed header plus a bulk body (esp_now_send) pass the two separately +// so they never need a build buffer of their own: both chunks are laid into the +// request buffer here, under g_req_mutex, which keeps concurrent callers from +// racing and saves a full copy of the body on every transmit. +esp_err_t request(uint8_t opcode, const void *payload, uint16_t plen, void *ret, uint16_t ret_cap, uint16_t *ret_len, + bool wait = true, const void *tail = nullptr, uint16_t tail_len = 0) { + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + if (plen > ESP_NOW_HOSTED_MAX_PAYLOAD || tail_len > ESP_NOW_HOSTED_MAX_PAYLOAD - plen) + return ESP_ERR_INVALID_SIZE; + const uint16_t total_len = static_cast(plen + tail_len); + + if (xSemaphoreTake(g_req_mutex, portMAX_DELAY) != pdTRUE) + return ESP_FAIL; + + static uint8_t buf[sizeof(esp_now_hosted_req_t) + ESP_NOW_HOSTED_MAX_PAYLOAD]; // guarded by g_req_mutex + auto *req = reinterpret_cast(buf); + req->opcode = opcode; + req->seq = ++g_seq; + req->payload_len = total_len; + if (plen != 0) + memcpy(req->payload, payload, plen); + if (tail_len != 0) + memcpy(req->payload + plen, tail, tail_len); + g_expect_seq = req->seq; + + xSemaphoreTake(g_resp_sem, 0); // drain any stale signal before sending + err = esp_hosted_send_custom_data(ESP_NOW_HOSTED_MSG_REQ, buf, sizeof(esp_now_hosted_req_t) + total_len); + if (err != ESP_OK) { + xSemaphoreGive(g_req_mutex); + return err; + } + if (!wait) { + // Fire-and-forget (esp_now_send): the co-processor enqueues the frame and + // reports the real TX result later via the async SEND event, exactly like + // native esp_now_send. Returning here keeps the main loop off the ~100 ms+ + // RPC round-trip. The matching RESP is ignored (seq won't match the next + // waited request, so on_resp drops it). + xSemaphoreGive(g_req_mutex); + return ESP_OK; + } + if (xSemaphoreTake(g_resp_sem, pdMS_TO_TICKS(ESP_NOW_HOSTED_TIMEOUT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "opcode %u timed out", opcode); + xSemaphoreGive(g_req_mutex); + return ESP_ERR_TIMEOUT; + } + + const int32_t status = g_resp_status; + if (ret != nullptr && ret_cap != 0) { + uint16_t n = g_resp_ret_len < ret_cap ? g_resp_ret_len : ret_cap; + memcpy(ret, const_cast(g_resp_ret), n); + if (ret_len != nullptr) + *ret_len = n; + } + xSemaphoreGive(g_req_mutex); + return static_cast(status); +} + +} // namespace + +// ── The surface, defined for the radio-less host ──────────────── +extern "C" { + +esp_err_t esp_now_init(void) { return request(ESP_NOW_HOSTED_OP_INIT, nullptr, 0, nullptr, 0, nullptr); } + +esp_err_t esp_now_deinit(void) { + g_recv_cb = nullptr; + g_send_cb = nullptr; + peer_cache_clear(); // the co-processor drops all peers on deinit + return request(ESP_NOW_HOSTED_OP_DEINIT, nullptr, 0, nullptr, 0, nullptr); +} + +esp_err_t esp_now_get_version(uint32_t *version) { + uint32_t v = 0; + uint16_t rl = 0; + esp_err_t err = request(ESP_NOW_HOSTED_OP_GET_VERSION, nullptr, 0, &v, sizeof(v), &rl); + if (version != nullptr) + *version = v; + return err; +} + +esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb) { + // Only arm the callback once the CustomRpc handlers are actually registered, + // so a failed setup leaves g_recv_cb null rather than falsely "registered". + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + g_recv_cb = cb; + return ESP_OK; +} +esp_err_t esp_now_unregister_recv_cb(void) { + g_recv_cb = nullptr; + return ESP_OK; +} +esp_err_t esp_now_register_send_cb(esp_now_send_cb_t cb) { + esp_err_t err = ensure_setup(); + if (err != ESP_OK) + return err; + g_send_cb = cb; + return ESP_OK; +} +esp_err_t esp_now_unregister_send_cb(void) { + g_send_cb = nullptr; + return ESP_OK; +} + +static esp_err_t add_or_mod_peer(uint8_t opcode, const esp_now_peer_info_t *peer, bool wait) { + if (peer == nullptr) + return ESP_ERR_ESPNOW_ARG; + esp_now_hosted_peer_t p; + memset(&p, 0, sizeof(p)); + memcpy(p.peer_addr, peer->peer_addr, 6); + memcpy(p.lmk, peer->lmk, 16); + p.channel = peer->channel; + p.ifidx = static_cast(peer->ifidx); + p.encrypt = peer->encrypt ? 1 : 0; + return request(opcode, &p, sizeof(p), nullptr, 0, nullptr, wait); +} +esp_err_t esp_now_add_peer(const esp_now_peer_info_t *peer) { + // Fire-and-forget (wait=false): adding a peer is a blocking RPC round-trip, + // and ESPHome's espnow calls it on the main loop when a device joins the mesh + // — under co-processor load that stalls the UI (peer-churn stutter). Issue it + // without waiting and mirror it locally. Safe against a following + // esp_now_send to the same peer: both ride the same in-order CustomRpc + // channel (mutex-serialized on the host) and the co-processor processes REQs + // FIFO, so ADD_PEER is applied before the SEND. Trade-off: a co-processor-side + // failure (e.g. peer table full) is no longer reported synchronously — the + // same limitation as esp_now_send — but ESPHome only adds peers it validated. + esp_err_t err = add_or_mod_peer(ESP_NOW_HOSTED_OP_ADD_PEER, peer, /*wait=*/false); + if (err == ESP_OK) + peer_cache_add(peer->peer_addr); // keep the local mirror in sync + return err; +} +esp_err_t esp_now_mod_peer(const esp_now_peer_info_t *peer) { + // mod_peer changes a peer's parameters, not its existence, so the cache is + // unaffected. Kept synchronous — it is not on any hot path (espnow never + // calls it), so the extra round-trip does not matter and the status is useful. + return add_or_mod_peer(ESP_NOW_HOSTED_OP_MOD_PEER, peer, /*wait=*/true); +} + +esp_err_t esp_now_del_peer(const uint8_t *peer_addr) { + if (peer_addr == nullptr) + return ESP_ERR_ESPNOW_ARG; + // Fire-and-forget for the same reason as add_peer (peer churn on the main + // loop). Removal is order-independent, so this is strictly safe. + esp_err_t err = request(ESP_NOW_HOSTED_OP_DEL_PEER, peer_addr, 6, nullptr, 0, nullptr, /*wait=*/false); + if (err == ESP_OK) + peer_cache_remove(peer_addr); // keep the local mirror in sync + return err; +} + +bool esp_now_is_peer_exist(const uint8_t *peer_addr) { + if (peer_addr == nullptr) + return false; + // Answered from the local mirror — no RPC round-trip. ESPHome's espnow calls + // this on the main loop for every received frame and every send, so a + // blocking round-trip here would stall rendering under mesh traffic. + return peer_cache_contains(peer_addr); +} + +esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len) { + if (len > ESP_NOW_HOSTED_MAX_FRAME) + return ESP_ERR_ESPNOW_ARG; + if (data == nullptr && len != 0) // native esp_now_send treats this as an arg error + return ESP_ERR_ESPNOW_ARG; + // Only the small fixed header is built here; the caller's frame goes over as + // the request tail, so request() lays both into its own buffer under + // g_req_mutex. esp_now_send is a public C symbol and may be called from any + // task, and a shared build buffer here would let two callers corrupt each + // other's frame. Passing the body through also drops a full-frame copy per + // transmit, on the path this shim exists to keep quick. + uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; + auto *s = reinterpret_cast(hdr); + s->has_addr = peer_addr != nullptr ? 1 : 0; + if (peer_addr != nullptr) + memcpy(s->peer_addr, peer_addr, 6); + else + memset(s->peer_addr, 0, 6); + s->data_len = static_cast(len); + // Fire-and-forget (wait=false): native esp_now_send returns once the frame is + // queued, with the real TX result delivered later through the send callback. + // The co-processor mirrors that — it acks enqueue immediately and reports the + // outcome via the async SEND event (on_send -> on_send_report). Waiting for + // the RPC RESP here would block the main loop for the full round-trip on + // every transmit. + return request(ESP_NOW_HOSTED_OP_SEND, hdr, sizeof(hdr), nullptr, 0, nullptr, /*wait=*/false, data, + static_cast(len)); +} + +esp_err_t esp_now_set_pmk(const uint8_t *pmk) { + if (pmk == nullptr) + return ESP_ERR_ESPNOW_ARG; + return request(ESP_NOW_HOSTED_OP_SET_PMK, pmk, 16, nullptr, 0, nullptr); +} + +// Remainder of the surface. Not used by ESPHome's espnow component +// today; provided so the whole header links and future callers get a defined +// (if unimplemented) symbol rather than a link error. Wire them through +// CustomRpc if a use case appears. +esp_err_t esp_now_get_peer(const uint8_t * /*peer_addr*/, esp_now_peer_info_t * /*peer*/) { + return ESP_ERR_NOT_SUPPORTED; +} +esp_err_t esp_now_fetch_peer(bool /*from_head*/, esp_now_peer_info_t * /*peer*/) { return ESP_ERR_NOT_SUPPORTED; } +esp_err_t esp_now_get_peer_num(esp_now_peer_num_t * /*num*/) { return ESP_ERR_NOT_SUPPORTED; } +esp_err_t esp_now_set_wake_window(uint16_t /*window*/) { + return ESP_ERR_NOT_SUPPORTED; // power-save wake window is not forwarded; don't claim success +} +esp_err_t esp_now_set_peer_rate_config(const uint8_t * /*peer_addr*/, esp_now_rate_config_t * /*cfg*/) { + return ESP_ERR_NOT_SUPPORTED; +} +esp_err_t esp_wifi_config_espnow_rate(wifi_interface_t /*ifx*/, wifi_phy_rate_t /*rate*/) { + return ESP_ERR_NOT_SUPPORTED; +} + +} // extern "C" + +#endif // CONFIG_IDF_TARGET_ESP32P4 diff --git a/esphome/components/esp32_hosted/esp_now_hosted_rpc.h b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h new file mode 100644 index 00000000000..bf68c759ee9 --- /dev/null +++ b/esphome/components/esp32_hosted/esp_now_hosted_rpc.h @@ -0,0 +1,128 @@ +/* + * esp_now_hosted — ESP-NOW-over-CustomRpc wire protocol. + * + * Shared, byte-for-byte-identical contract between: + * - the host shim (esphome/components/esp32_hosted/esp_now_hosted.cpp) + * - the coprocessor firmware (esphome/esp-hosted-firmware) + * + * It rides esp-hosted's CustomRpc channel (RPC ID 388, "peer data transfer", + * available since esp-hosted v2.8.1), teaching the radio-less host <-> radio + * co-processor link to carry esp_now.h, which esp-hosted itself does not proxy + * (Espressif issue espressif/esp-hosted-mcu#19). + * + * KEEP THE TWO COPIES IN SYNC. The canonical copy lives here; the coprocessor + * firmware uses a verbatim copy. Both sides are little-endian, so these packed + * structs are wire-compatible with no byte-swapping. + */ + +#ifndef ESP_NOW_HOSTED_RPC_H +#define ESP_NOW_HOSTED_RPC_H + +#ifdef __cplusplus +#include +#else +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ── CustomRpc message IDs (any uint32_t except 0xFFFFFFFF) ────────────────── + * One REQ handler slot on the device; three event handler slots on the host. + * The bytes spell "now" + index, a private range unlikely to clash with other + * CustomRpc users (e.g. the stock peer_data_transfer example's 1..6). */ +#define ESP_NOW_HOSTED_MSG_REQ 0x6E6F7701u /* host -> device : request envelope */ +#define ESP_NOW_HOSTED_MSG_RESP 0x6E6F7702u /* device -> host : reply to a REQ */ +#define ESP_NOW_HOSTED_MSG_RECV 0x6E6F7703u /* device -> host : async RX frame */ +#define ESP_NOW_HOSTED_MSG_SEND 0x6E6F7704u /* device -> host : async TX status */ + +/* ── Request opcodes ────────────────────────────────────────────────────── */ +enum { + ESP_NOW_HOSTED_OP_INIT = 1, /* esp_now_init + register device recv/send cbs */ + ESP_NOW_HOSTED_OP_DEINIT = 2, /* unregister cbs + esp_now_deinit */ + ESP_NOW_HOSTED_OP_ADD_PEER = 3, /* payload: esp_now_hosted_peer_t */ + ESP_NOW_HOSTED_OP_DEL_PEER = 4, /* payload: 6-byte peer MAC */ + ESP_NOW_HOSTED_OP_IS_PEER_EXIST = 5, /* payload: 6-byte MAC; ret: 1 byte bool */ + ESP_NOW_HOSTED_OP_SEND = 6, /* payload: esp_now_hosted_send_req_t */ + ESP_NOW_HOSTED_OP_GET_VERSION = 7, /* ret: uint32 version */ + ESP_NOW_HOSTED_OP_SET_PMK = 8, /* payload: 16-byte PMK */ + ESP_NOW_HOSTED_OP_MOD_PEER = 9, /* payload: esp_now_hosted_peer_t */ +}; + +/* Largest ESP-NOW payload we forward. ESP-NOW v2 (IDF >= 5.4) is 1470 B; well + * under esp-hosted's 8166 B CustomRpc cap, so the shim never truncates. */ +#define ESP_NOW_HOSTED_MAX_FRAME 1470u +/* Envelope slack for the largest opcode payload (a SEND req wrapping a frame). */ +#define ESP_NOW_HOSTED_MAX_PAYLOAD (ESP_NOW_HOSTED_MAX_FRAME + 16u) +/* Host request/response round-trip timeout over the transport. Generous: + * normal RTT is sub-millisecond, but Wi-Fi/BLE contention on the co-processor + * can stall the RX thread. */ +#define ESP_NOW_HOSTED_TIMEOUT_MS 2000 + +/* ── Envelopes ──────────────────────────────────────────────────────────── */ + +/* These payloads are shared verbatim with the C co-processor firmware, so they + * use C's `typedef struct {...} name;` idiom rather than C++ `using` aliases, + * which would not compile there. Silence clang-tidy's modernize-use-using for + * the shared struct block. */ +// NOLINTBEGIN(modernize-use-using) +typedef struct { + uint8_t opcode; /* one of ESP_NOW_HOSTED_OP_* */ + uint8_t seq; /* wraps 0..255; echoed in the response for matching */ + uint16_t payload_len; /* bytes of opcode-specific payload that follow */ + uint8_t payload[]; /* flexible */ +} __attribute__((packed)) esp_now_hosted_req_t; + +typedef struct { + uint8_t opcode; /* echoes the request opcode */ + uint8_t seq; /* echoes the request seq */ + int32_t status; /* esp_err_t from the native call on the co-processor */ + uint16_t ret_len; /* bytes of return payload that follow */ + uint8_t ret[]; /* flexible (e.g. version u32, is_peer_exist bool) */ +} __attribute__((packed)) esp_now_hosted_resp_t; + +/* ── Opcode payloads ────────────────────────────────────────────────────── */ + +/* esp_now_peer_info_t minus the host-only `priv` pointer, which is meaningless + * across the transport and never set by ESPHome's espnow component. */ +typedef struct { + uint8_t peer_addr[6]; + uint8_t lmk[16]; + uint8_t channel; /* 0 = current channel */ + uint8_t ifidx; /* wifi_interface_t (0=STA, 1=AP) */ + uint8_t encrypt; /* bool */ +} __attribute__((packed)) esp_now_hosted_peer_t; + +typedef struct { + uint8_t has_addr; /* 0 => peer_addr is NULL (broadcast to all peers) */ + uint8_t peer_addr[6]; + uint16_t data_len; + uint8_t data[]; /* flexible, up to ESP_NOW_HOSTED_MAX_FRAME */ +} __attribute__((packed)) esp_now_hosted_send_req_t; + +/* ── Async events (device -> host) ──────────────────────────────────────── */ + +/* Reconstructed on the host into an esp_now_recv_info_t + a minimal + * wifi_pkt_rx_ctrl_t. ESPHome's espnow reads info->src_addr, info->des_addr, + * info->rx_ctrl->rssi and info->rx_ctrl->timestamp. */ +typedef struct { + uint8_t src_addr[6]; + uint8_t des_addr[6]; + int8_t rssi; + uint8_t channel; + uint16_t data_len; + uint8_t data[]; /* flexible */ +} __attribute__((packed)) esp_now_hosted_recv_evt_t; + +typedef struct { + uint8_t des_addr[6]; + uint8_t status; /* esp_now_send_status_t (0 = success) */ +} __attribute__((packed)) esp_now_hosted_send_evt_t; +// NOLINTEND(modernize-use-using) + +#ifdef __cplusplus +} +#endif + +#endif /* ESP_NOW_HOSTED_RPC_H */ diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index 351b0869b08..4eb5d1745be 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -135,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 @@ -185,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) { @@ -197,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_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index ad2f057163b..32eb1660142 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/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 6e3a4ef526a..9ec6eb7bab6 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" @@ -19,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 @@ -104,6 +112,7 @@ void ESP32ImprovComponent::loop() { this->state_callback_.call(this->state_, this->error_state_); #endif } + this->release_advertising_(); this->incoming_data_.clear(); return; } @@ -135,8 +144,9 @@ void ESP32ImprovComponent::loop() { ESP_LOGV(TAG, "Starting with device name advertising"); this->advertising_device_name_ = true; this->last_name_adv_time_ = App.get_loop_component_start_time(); + // Set the payload before requesting, so advertising starts exactly once esp32_ble::global_ble->advertising_set_service_data_and_name(std::span{}, true); - esp32_ble::global_ble->advertising_start(); + this->request_advertising_(); // Set initial state based on whether we have an authorizer this->set_state_(this->get_initial_state_(), false); @@ -285,8 +295,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(); } @@ -317,6 +328,8 @@ void ESP32ImprovComponent::stop() { this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; + // Release first so removing the service UUID does not restart advertising on the way out + this->release_advertising_(); this->service_->stop(); this->set_state_(improv::STATE_STOPPED); }); @@ -430,40 +443,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"); } @@ -516,6 +524,20 @@ void ESP32ImprovComponent::update_advertising_type_() { } } +void ESP32ImprovComponent::request_advertising_() { + if (this->advertising_requested_) + return; + this->advertising_requested_ = true; + esp32_ble::global_ble->advertising_start(); +} + +void ESP32ImprovComponent::release_advertising_() { + if (!this->advertising_requested_) + return; + this->advertising_requested_ = false; + esp32_ble::global_ble->advertising_stop(); +} + improv::State ESP32ImprovComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index d948dba3b3b..a40d60552a8 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 @@ -103,13 +104,16 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool status_indicator_state_{false}; uint32_t last_name_adv_time_{0}; bool advertising_device_name_{false}; + bool advertising_requested_{false}; void set_status_indicator_state_(bool state); void update_advertising_type_(); + void request_advertising_(); + void release_advertising_(); 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 1076bcabdc9..a213a787786 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 ed2a8c5a686..7cac1dfb41c 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 d7ba2aafbf8..61aac06d76e 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,15 +16,6 @@ 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; @@ -39,7 +31,7 @@ class ESP32RMTLEDStripLightOutput final : 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 final : 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 final : 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 final : 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 final : 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 1c6943b0037..571b7d93b8b 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 10ad339b127..ede6beb9b6e 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 75560d71b1f..2489c2abc1f 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/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 7ce10d465d7..19dbb68f29a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -3,6 +3,7 @@ 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 @@ -30,9 +31,11 @@ from esphome.core import ( ) from esphome.core.config import BOARD_MAX_LENGTH 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, board_ld_script from .const import ( CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, @@ -40,7 +43,6 @@ from .const import ( CONF_RESTORE_FROM_FLASH, KEY_BOARD, KEY_ESP8266, - KEY_FLASH_SIZE, KEY_PIN_INITIAL_STATES, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, @@ -87,7 +89,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" @@ -101,7 +103,7 @@ 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 device-builder (esphome/device-builder), via @@ -112,6 +114,9 @@ def get_download_types(storage_json): 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", @@ -126,11 +131,16 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/esp8266/Arduino/releases) version to # a PIO platformio/framework-arduinoespressif8266 value # List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 - if ver <= cv.Version(2, 4, 1): - 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: @@ -143,17 +153,15 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # - https://github.com/esp8266/Arduino/releases # - https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 1, 2) -# The platformio/espressif8266 version to use for arduino 2 framework versions +# The platformio/espressif8266 version to use for arduino 3 framework versions # - https://github.com/platformio/platform-espressif8266/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif8266 -ARDUINO_2_PLATFORM_VERSION = cv.Version(2, 6, 3) -# for arduino 3 framework versions ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0) # for arduino 4 framework versions 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"), @@ -172,6 +180,14 @@ def _arduino_check_versions(value): version = cv.Version.parse(cv.version_number(value[CONF_VERSION])) source = value.get(CONF_SOURCE, None) + if version < cv.Version(3, 0, 0): + raise cv.Invalid( + f"Arduino framework {version} is no longer supported; ESPHome requires " + f"C++20, which needs Arduino core 3.x. Use the recommended version " + f"({RECOMMENDED_ARDUINO_FRAMEWORK_VERSION}).", + path=[CONF_VERSION], + ) + value[CONF_VERSION] = str(version) value[CONF_SOURCE] = source or _format_framework_arduino_version(version) @@ -179,12 +195,8 @@ def _arduino_check_versions(value): if platform_version is None: if version >= cv.Version(3, 1, 0): platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) - elif version >= cv.Version(3, 0, 0): - platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) - elif version >= cv.Version(2, 5, 0): - platform_version = _parse_platform_version(str(ARDUINO_2_PLATFORM_VERSION)) else: - platform_version = _parse_platform_version(str(cv.Version(1, 8, 0))) + platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) value[CONF_PLATFORM_VERSION] = platform_version if version != RECOMMENDED_ARDUINO_FRAMEWORK_VERSION: @@ -196,7 +208,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) @@ -240,6 +252,9 @@ 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, ) @@ -270,8 +285,15 @@ def check_rosetta() -> None: ) +def _choose_ld_script(board: str) -> str: + """The flash ld to pin for this board.""" + # A per-board override preserves a layout the board shipped with + # (see d1_wroom_02 in boards.py) + return board_ld_script(BOARDS[board]) + + @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") @@ -294,9 +316,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") @@ -389,20 +413,9 @@ 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] - - if ld_script is not None: - cg.add_platformio_option("board_build.ldscript", ld_script) + cg.add_platformio_option( + "board_build.ldscript", _choose_ld_script(config[CONF_BOARD]) + ) CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) @@ -443,31 +456,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 @@ -510,7 +511,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) @@ -531,7 +532,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)) @@ -555,7 +556,7 @@ STACKTRACE_BAD_ALLOC_RE = re.compile( 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) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 02bfa9e6628..268c6b50aa0 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 00000000000..eb6ed1b91ba --- /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 229ac61f245..50f103ed2db 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 91b0cf90827..dc79043f217 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.py b/esphome/components/esp8266/gpio.py index 64be4a64952..356af6e0063 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/relocate_ratetable.py.script b/esphome/components/esp8266/relocate_ratetable.py.script new file mode 100644 index 00000000000..c9d0ba166d3 --- /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_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index be58a098b6e..79c2e509848 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -29,7 +29,7 @@ class ESP8266PWM final : public output::FloatOutput, public Component { void write_state(float state) override; InternalGPIOPin *pin_; - float frequency_{1000.0}; + float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index f119a6ba9f3..be6e63b154d 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 @@ -19,6 +22,10 @@ ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Componen SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action) validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6)) +# Schema default that also matches the C++ initializer in esp8266_pwm.h; codegen +# skips the setter when the config equals it. +DEFAULT_FREQUENCY = 1000.0 + CONFIG_SCHEMA = cv.All( output.FLOAT_OUTPUT_SCHEMA.extend( { @@ -26,7 +33,7 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_PIN): cv.All( pins.internal_gpio_output_pin_schema, valid_pwm_pin ), - cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency, + cv.Optional(CONF_FREQUENCY, default=DEFAULT_FREQUENCY): validate_frequency, } ).extend(cv.COMPONENT_SCHEMA), cv.require_framework_version( @@ -35,7 +42,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]) @@ -45,7 +52,9 @@ async def to_code(config) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_frequency(config[CONF_FREQUENCY])) + # Skip the setter when the config matches the C++ initializer (DEFAULT_FREQUENCY). + if (frequency := config[CONF_FREQUENCY]) != DEFAULT_FREQUENCY: + cg.add(var.set_frequency(frequency)) @automation.register_action( @@ -59,7 +68,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 a489651b598..46810d422de 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/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index d5f4efbc025..f5eb878260c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -1,12 +1,20 @@ import logging import esphome.codegen as cg +from esphome.components.noise import ( + encryption_schema, + new_psk_progmem, + static_encryption_key, +) from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code -from esphome.config_helpers import merge_config +from esphome.config_helpers import filter_source_files_from_defines, merge_config import esphome.config_validation as cv from esphome.const import ( + CONF_API, + CONF_ENCRYPTION, CONF_ESPHOME, CONF_ID, + CONF_KEY, CONF_NUM_ATTEMPTS, CONF_OTA, CONF_PASSWORD, @@ -15,6 +23,7 @@ from esphome.const import ( CONF_REBOOT_TIMEOUT, CONF_SAFE_MODE, CONF_VERSION, + CONF_WEB_SERVER, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -30,14 +39,21 @@ CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] -AUTO_LOAD = ["sha256", "socket"] +def AUTO_LOAD(config: ConfigType) -> list[str]: + """Auto-load noise only when encryption is configured; the api key offer + inherits it from the api component.""" + base = ["sha256", "socket"] + # A falsy config is a tooling probe for the maximal set + if not config or CONF_ENCRYPTION in config: + return base + ["noise"] + return base 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 = [] @@ -67,11 +83,24 @@ def ota_esphome_final_validate(config): CONF_PASSWORD in merged_ota_esphome_configs_by_port[conf_port] and CONF_PASSWORD in ota_conf and merged_ota_esphome_configs_by_port[conf_port][CONF_PASSWORD] - != ota_conf.get(CONF_PASSWORD) + != ota_conf[CONF_PASSWORD] ): raise cv.Invalid( f"Found multiple configurations but {CONF_PASSWORD} is inconsistent" ) + # Encryption blocks conflict only when both pin a key; a bare + # `encryption:` (a package/device split) is compatible with a + # keyed one, and merge_config yields the keyed result + merged_key = ( + merged_ota_esphome_configs_by_port[conf_port] + .get(CONF_ENCRYPTION, {}) + .get(CONF_KEY) + ) + other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -94,6 +123,64 @@ def ota_esphome_final_validate(config): new_ota_conf.extend(merged_ota_esphome_configs_by_port.values()) + api_conf = full_conf.get(CONF_API) or {} + for ota_conf in merged_ota_esphome_configs_by_port.values(): + # Merging same-port blocks can combine a password from one block with + # encryption from another; re-check the exclusion on the merged result. + _validate_no_password_with_encryption(ota_conf) + if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: + _resolve_encryption_key(encryption_conf, api_conf) + elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None: + _LOGGER.warning( + "'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 " + "bytes plus the password on the heap): the device already offers " + "encryption with the '%s' %s %s, which authenticates any uploader " + "that takes it, and a password only matters for uploaders without " + "encryption support; remove '%s' and add '%s' under '%s' so " + "uploads use the key and encryption is required", + CONF_OTA, + CONF_PASSWORD, + CONF_API, + CONF_ENCRYPTION, + CONF_KEY, + CONF_PASSWORD, + CONF_ENCRYPTION, + CONF_OTA, + ) + elif ( + CONF_PASSWORD in ota_conf + and CONF_ENCRYPTION in api_conf + and not api_conf[CONF_ENCRYPTION].get(CONF_KEY) + ): + # The CLI still needs the password; whoever provisions the key skips it + _LOGGER.warning( + "The '%s' %s %s provisioned at runtime also authenticates OTA " + "uploads once provisioned; '%s' %s then only guards plaintext " + "uploads. Whoever provisions the key can upload firmware " + "without the password, so add a 'provisioning:' block to limit " + "when that is possible", + CONF_API, + CONF_ENCRYPTION, + CONF_KEY, + CONF_OTA, + CONF_PASSWORD, + ) + # web_server and prometheus keep the shared listener up; the captive + # portal's copy only exists on the fallback AP and is the recovery path + if ( + (CONF_WEB_SERVER in full_conf or "prometheus" in full_conf) + and any(conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf) + and any( + CONF_ENCRYPTION in conf + for conf in merged_ota_esphome_configs_by_port.values() + ) + ): + _LOGGER.warning( + "OTA encryption does not cover the %s OTA platform; its " + "plaintext /update endpoint accepts the same image", + CONF_WEB_SERVER, + ) + full_conf[CONF_OTA] = new_ota_conf fv.full_config.set(full_conf) @@ -107,6 +194,46 @@ def ota_esphome_final_validate(config): ) +def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) -> None: + """Resolve the one encryption key per device into the ota block. + + An explicit ota key must match the api key, a bare block inherits it, + a runtime provisioned api key cannot be inherited. + """ + api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) + if ota_key := encryption_conf.get(CONF_KEY): + if api_key and ota_key != api_key: + raise cv.Invalid( + f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY} must match the " + f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY}; omit the " + f"'{CONF_OTA}' {CONF_KEY} to use the '{CONF_API}' one" + ) + elif not api_key: + if CONF_ENCRYPTION in api_conf: + raise cv.Invalid( + f"the '{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} is provisioned at " + f"runtime and cannot be inherited at build time; set an explicit " + f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY}" + ) + raise cv.Invalid( + f"'{CONF_OTA}' {CONF_ENCRYPTION} has no {CONF_KEY} and there is no " + f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} to inherit; set one of them" + ) + else: + encryption_conf[CONF_KEY] = api_key + + +# Also called on merged same-port configs in final validate, where schemas +# do not run +def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType: + if CONF_PASSWORD in config and CONF_ENCRYPTION in config: + raise cv.Invalid( + f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the " + f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'" + ) + return config + + def _consume_ota_sockets(config: ConfigType) -> ConfigType: """Register socket needs for OTA component.""" from esphome.components import socket @@ -134,6 +261,7 @@ CONFIG_SCHEMA = cv.All( ): cv.port, cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean, cv.Optional(CONF_PASSWORD): cv.sensitive(), + cv.Optional(CONF_ENCRYPTION): encryption_schema, 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" ), @@ -147,12 +275,18 @@ CONFIG_SCHEMA = cv.All( ) .extend(BASE_OTA_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + _validate_no_password_with_encryption, _consume_ota_sockets, ) FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"} +) + + @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -171,6 +305,25 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") + # One key per device: an api encryption block supplies it (static or + # runtime) and offers; the ota block only adds the requirement + api_conf = CORE.config.get(CONF_API) or {} + encryption_conf = config.get(CONF_ENCRYPTION) + own_key = None + if encryption_conf is not None and static_encryption_key(api_conf) is None: + own_key = encryption_conf[CONF_KEY] + if own_key is not None: + cg.add_define("USE_OTA_ENCRYPTION") + cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], own_key))) + elif CONF_ENCRYPTION in api_conf: + cg.add_define("USE_OTA_ENCRYPTION") + cg.add_define("USE_OTA_ENCRYPTION_FROM_API") + if static_encryption_key(api_conf) is None: + # The key arrives at runtime, so the offer has to look for it + cg.add_define("USE_OTA_ENCRYPTION_PROVISIONED") + if encryption_conf is not None: + cg.add_define("USE_OTA_ENCRYPTION_REQUIRED") + # Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it. cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index cab725f704a..3010df10561 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -1,4 +1,7 @@ #include "ota_esphome.h" +#ifdef USE_OTA_ENCRYPTION_FROM_API +#include "esphome/components/api/api_server.h" +#endif #ifdef USE_OTA #ifdef USE_OTA_PASSWORD #include "esphome/components/sha256/sha256.h" @@ -26,10 +29,22 @@ namespace esphome { static const char *const TAG = "esphome.ota"; + +#ifdef USE_OTA_ENCRYPTION +const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { +#ifdef USE_OTA_ENCRYPTION_FROM_API + return api::global_api_server->get_noise_ctx(); +#else + return this->noise_ctx_; +#endif +} +#endif static constexpr uint16_t OTA_BLOCK_SIZE = 8192; -static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake -static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer +// Milliseconds for data transfer. Covers the lwIP retransmit run seen in +// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits +// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries +static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000; // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -98,8 +113,25 @@ void ESPHomeOTAComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates:\n" " Address: %s:%u\n" - " Version: %d", - network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION); + " Version: %d" +#ifdef USE_OTA_ENCRYPTION + "\n Encryption: %s" +#endif + , + network::get_use_address_to(addr_buf), this->port_, USE_OTA_VERSION +#ifdef USE_OTA_ENCRYPTION_REQUIRED + , + LOG_STR_LITERAL("required") +#elif defined(USE_OTA_ENCRYPTION_PROVISIONED) + // A runtime provisioned key may not exist yet + , + this->noise_context_().has_psk() ? LOG_STR_LITERAL("offered, plaintext accepted") + : LOG_STR_LITERAL("offered once the api key is provisioned") +#elif defined(USE_OTA_ENCRYPTION) + , + LOG_STR_LITERAL("offered, plaintext accepted") +#endif + ); #ifdef USE_OTA_PASSWORD if (!this->password_.empty()) { ESP_LOGCONFIG(TAG, " Password configured"); @@ -128,7 +160,8 @@ void ESPHomeOTAComponent::dump_config() { esp_partition_iterator_release(it); esp_bootloader_desc_t bootloader_desc; esp_err_t err = esp_ota_get_bootloader_description(nullptr, &bootloader_desc); - ESP_LOGCONFIG(TAG, " Bootloader: ESP-IDF %s", (err == ESP_OK) ? bootloader_desc.idf_ver : "version unknown"); + ESP_LOGCONFIG(TAG, " Bootloader: ESP-IDF %s", + (err == ESP_OK) ? bootloader_desc.idf_ver : LOG_STR_LITERAL("version unknown")); #endif // USE_ESP32 #endif // USE_OTA_PARTITIONS } @@ -148,8 +181,22 @@ void ESPHomeOTAComponent::loop() { static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; +// Noise needs the extended protocol: the prologue binds the 2-byte feature ack +static constexpr uint8_t CLIENT_NOISE_FEATURES = + CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; + +inline bool ESPHomeOTAComponent::extended_proto_() const { +#ifdef USE_OTA_ENCRYPTION_REQUIRED + // FEATURE_READ already refused every client without the extended protocol + return true; +#else + return (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; +#endif +} void ESPHomeOTAComponent::handle_handshake_() { /// Handle the OTA handshake and authentication. @@ -201,8 +248,7 @@ void ESPHomeOTAComponent::handle_handshake_() { } // Validate magic bytes - static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; - if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) { + if (memcmp(this->handshake_buf_, MAGIC_BYTES, sizeof(MAGIC_BYTES)) != 0) { ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC); @@ -234,6 +280,16 @@ void ESPHomeOTAComponent::handle_handshake_() { } this->ota_features_ = this->handshake_buf_[0]; ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_); + +#ifdef USE_OTA_ENCRYPTION_REQUIRED + // `ota: encryption:` requires the client to negotiate encryption + if ((this->ota_features_ & CLIENT_NOISE_FEATURES) != CLIENT_NOISE_FEATURES) { + ESP_LOGW(TAG, "Client does not support encryption"); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED); + return; + } +#endif + this->transition_ota_state_(OTAState::FEATURE_ACK); const bool supports_compression = @@ -242,13 +298,21 @@ void ESPHomeOTAComponent::handle_handshake_() { // Compose the feature-ack response. When the client negotiates the extended protocol we emit // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte // legacy response. - this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0; - if (this->extended_proto_) { + if (this->extended_proto_()) { static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack"); this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS; this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0); #ifdef USE_OTA_PARTITIONS this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS; +#endif +#ifdef USE_OTA_ENCRYPTION_PROVISIONED + // A runtime provisioned key may not exist yet + if (this->noise_context_().has_psk()) { + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; + } +#elif defined(USE_OTA_ENCRYPTION) + // A yaml key always exists: validation rejects the all-zeros key + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; #endif } else { this->handshake_buf_[0] = @@ -260,10 +324,24 @@ void ESPHomeOTAComponent::handle_handshake_() { case OTAState::FEATURE_ACK: { static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1; static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2; - const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; + const size_t ack_size = this->extended_proto_() ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE; if (!this->try_write_(ack_size, LOG_STR("ack feature"))) { return; } +#ifdef USE_OTA_ENCRYPTION + // Latch the offer actually sent: a key activating between the two + // states must not start a session the client never expects + if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 && + (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) { + // handshake_buf_ still holds the feature ack composed above; a + // would-block re-entry lands here without rebuilding it + if (!this->noise_start_session_(this->handshake_buf_[1])) { + return; + } + this->transition_ota_state_(OTAState::NOISE_HANDSHAKE); + return; + } +#endif #ifdef USE_OTA_PASSWORD // If password is set, move to auth phase if (!this->password_.empty()) { @@ -301,6 +379,16 @@ void ESPHomeOTAComponent::handle_handshake_() { this->handle_data_(); return; +#ifdef USE_OTA_ENCRYPTION + case OTAState::NOISE_HANDSHAKE: + if (!this->handle_noise_handshake_()) { + return; + } + this->transition_ota_state_(OTAState::DATA); + this->handle_data_(); + return; +#endif + default: break; } @@ -339,6 +427,8 @@ void ESPHomeOTAComponent::handle_data_() { /// Raw TCP (8266, RP2040): setblocking is no-op; SO_RCVTIMEO uses /// wakeable_delay() in read(); /// write() always returns immediately + // Backend calls overwrite this with OK; reset to UNKNOWN before any + // goto error that follows a successful begin()/write() ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; size_t total = 0; uint32_t last_progress = 0; @@ -360,11 +450,11 @@ void ESPHomeOTAComponent::handle_data_() { this->client_->setblocking(true); // Acknowledge auth OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_AUTH_OK); + this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK); - if (this->extended_proto_) { + if (this->extended_proto_()) { // Read ota type, 1 byte - if (!this->readall_(buf, 1)) { + if (!this->data_readall_(buf, 1)) { this->log_read_error_(LOG_STR("OTA type")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -373,7 +463,7 @@ void ESPHomeOTAComponent::handle_data_() { ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); // Read size, 4 bytes MSB first - if (!this->readall_(buf, 4)) { + if (!this->data_readall_(buf, 4)) { this->log_read_error_(LOG_STR("size")); goto error; // NOLINT(cppcoreguidelines-avoid-goto) } @@ -398,17 +488,18 @@ 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) // Acknowledge prepare OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK); + this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK); // Read binary MD5, 32 bytes - if (!this->readall_(buf, 32)) { + if (!this->data_readall_(buf, 32)) { this->log_read_error_(LOG_STR("MD5 checksum")); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } sbuf[32] = '\0'; @@ -416,7 +507,7 @@ void ESPHomeOTAComponent::handle_data_() { this->backend_->set_update_md5(sbuf); // Acknowledge MD5 OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); + this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); // Track when we last received data so a silently-vanished peer (no FIN/RST // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) @@ -432,19 +523,35 @@ void ESPHomeOTAComponent::handle_data_() { } size_t remaining = ota_size - total; size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; - ssize_t read = this->client_->read(buf, requested); - if (read == -1) { - const int err = errno; - if (this->would_block_(err)) { - // read() already waited up to SO_RCVTIMEO for data, just feed WDT - App.feed_wdt(); - continue; + ssize_t read; +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) { + // One frame per call; noise_read_data_ waits internally (readall_), so + // there is no would-block retry here and failures are already logged. + read = this->noise_read_data_(buf, requested); + if (read <= 0) { + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } + } else +#endif + { + read = this->client_->read(buf, requested); + if (read == -1) { + const int err = errno; + if (this->would_block_(err)) { + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); + continue; + } + ESP_LOGW(TAG, "Read err %d", err); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } else if (read == 0) { + ESP_LOGW(TAG, "Remote closed"); + error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - ESP_LOGW(TAG, "Read err %d", err); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) } last_data_ms = millis(); @@ -456,7 +563,7 @@ void ESPHomeOTAComponent::handle_data_() { total += read; #if USE_OTA_VERSION == 2 while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) { - this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK); + this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); size_acknowledged += OTA_BLOCK_SIZE; } #endif @@ -475,7 +582,7 @@ void ESPHomeOTAComponent::handle_data_() { } // Acknowledge receive OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK); + this->data_write_byte_(ota::OTA_RESPONSE_RECEIVE_OK); error_code = this->backend_->end(); if (error_code != ota::OTA_RESPONSE_OK) { @@ -484,10 +591,10 @@ void ESPHomeOTAComponent::handle_data_() { } // Acknowledge Update end OK - 1 byte - this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); + this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK); // Read ACK - if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { + if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) { this->log_read_error_(LOG_STR("ack")); // do not go to error, this is not fatal } @@ -510,7 +617,7 @@ void ESPHomeOTAComponent::handle_data_() { App.safe_reboot(); error: - this->write_byte_(static_cast(error_code)); + this->data_write_byte_(static_cast(error_code)); // Abort backend before cleanup - cleanup_connection_() destroys the backend. // Always call abort() unconditionally: backends register external partitions before @@ -588,8 +695,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); @@ -679,6 +784,9 @@ void ESPHomeOTAComponent::cleanup_connection_() { this->backend_ = nullptr; #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); +#endif +#ifdef USE_OTA_ENCRYPTION + this->noise_ = nullptr; #endif // Intentionally no disable_loop() — letting loop() run one more iteration catches // any connection that queued on the listener mid-session (otherwise the wake flag, @@ -734,7 +842,14 @@ bool ESPHomeOTAComponent::handle_auth_send_() { const size_t hex_size = hasher.get_size() * 2; const size_t nonce_len = hasher.get_size() / 4; const size_t auth_buf_size = 1 + 3 * hex_size; - this->auth_buf_ = std::make_unique(auth_buf_size); + // Internal RAM first: 128 of these bytes go straight into the hardware SHA engine + this->auth_buf_ = + RAMAllocator(RAMAllocator::PREFER_INTERNAL).make_unique_array_for_overwrite(auth_buf_size); + if (!this->auth_buf_) { + this->log_auth_warning_(LOG_STR("No memory")); + this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_UNKNOWN); + return false; + } this->auth_buf_pos_ = 0; char *buf = reinterpret_cast(this->auth_buf_.get() + 1); diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 0053ca69691..68dd0ffb9ef 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -4,6 +4,9 @@ #ifdef USE_OTA #include "esphome/components/ota/ota_backend_factory.h" #include "esphome/components/socket/socket.h" +#ifdef USE_OTA_ENCRYPTION +#include "esphome/components/noise/noise_handshake.h" +#endif #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" @@ -24,7 +27,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { AUTH_SEND, // Sending authentication request AUTH_READ, // Reading authentication data #endif // USE_OTA_PASSWORD - DATA, // BLOCKING! Processing OTA data (update, etc.) +#ifdef USE_OTA_ENCRYPTION + NOISE_HANDSHAKE, // Exchanging Noise handshake frames +#endif + DATA, // BLOCKING! Processing OTA data (update, etc.) }; #ifdef USE_OTA_PASSWORD void set_auth_password(const std::string &password) { password_ = password; } @@ -38,15 +44,20 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { } #endif // USE_OTA_PASSWORD +#if defined(USE_OTA_ENCRYPTION) && !defined(USE_OTA_ENCRYPTION_FROM_API) + /// psk points at 32 bytes that live in flash for the life of the program + void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } +#endif + /// 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_(); @@ -63,6 +74,51 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { bool writeall_(const uint8_t *buf, size_t len); inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); } +#ifdef USE_OTA_ENCRYPTION + // Heap-allocated only while an encrypted OTA session is active. + struct NoiseSession { + ~NoiseSession(); + noise::NoiseResponderHandshake handshake; + NoiseCipherState *send_cipher{nullptr}; + NoiseCipherState *recv_cipher{nullptr}; + uint16_t frame_len{0}; // total frame size once the header is parsed, 0 until then + uint16_t frame_pos{0}; // bytes read or written so far + bool writing{false}; // a produced handshake frame is still being flushed + uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE]; + }; + // The api server's live context when the api has encryption, else our own + const noise::NoiseContext &noise_context_() const; + bool noise_start_session_(uint8_t server_feature_flags); + bool handle_noise_handshake_(); + bool noise_try_read_frame_(); + size_t noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len); + bool noise_try_write_frame_(); + void noise_send_reject_(const LogString *reason); + ssize_t noise_decrypt_(uint8_t *buf, size_t len); + ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext); + bool noise_readall_(uint8_t *buf, size_t len); + ssize_t noise_read_data_(uint8_t *buf, size_t capacity); + bool noise_write_byte_(uint8_t byte); +#endif // USE_OTA_ENCRYPTION + + // Data-phase I/O dispatch: through the noise transport when a session is + // active, straight to the socket otherwise. + inline bool data_write_byte_(uint8_t byte) { +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) + return this->noise_write_byte_(byte); +#endif + return this->write_byte_(byte); + } + // When encrypted, buf must have room for len + noise::MAC_SIZE bytes. + inline bool data_readall_(uint8_t *buf, size_t len) { +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) + return this->noise_readall_(buf, len); +#endif + return this->readall_(buf, len); + } + bool try_read_(size_t to_read, const LogString *desc); bool try_write_(size_t to_write, const LogString *desc); @@ -89,8 +145,14 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { #ifdef USE_OTA_PASSWORD std::string password_; - std::unique_ptr auth_buf_; + RAMUniquePtr auth_buf_; #endif // USE_OTA_PASSWORD +#ifdef USE_OTA_ENCRYPTION +#ifndef USE_OTA_ENCRYPTION_FROM_API + noise::NoiseContext noise_ctx_; +#endif + RAMUniquePtr noise_; +#endif // USE_OTA_ENCRYPTION socket::ListenSocket *server_{nullptr}; std::unique_ptr client_; @@ -98,6 +160,20 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint32_t client_connect_time_{0}; static constexpr size_t HANDSHAKE_BUF_SIZE = 5; + // Buffer size for OTA data transfer. The upload client derives its maximum + // encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this + // minus the 16-byte MAC); both must change together. + static constexpr size_t OTA_BUFFER_SIZE = 1040; +#ifdef USE_OTA_ENCRYPTION + // espota2.NOISE_MAX_PLAINTEXT; shrinking the buffer would reject every + // frame a current CLI sends + static constexpr size_t NOISE_CLIENT_MAX_PLAINTEXT = 1024; + static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE, + "OTA_BUFFER_SIZE must fit a full encrypted data frame"); +#endif + static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; + // Derived from the feature byte; storing it would pad the trailing bytes + bool extended_proto_() const; #ifdef USE_OTA_PARTITIONS uint32_t running_app_offset_{0}; size_t running_app_size_{0}; @@ -111,7 +187,6 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { uint8_t auth_buf_pos_{0}; uint8_t auth_type_{0}; // Store auth type to know which hasher to use #endif // USE_OTA_PASSWORD - bool extended_proto_{false}; }; } // namespace esphome diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp new file mode 100644 index 00000000000..65476572a1e --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -0,0 +1,284 @@ +#include "ota_esphome.h" +#ifdef USE_OTA +#ifdef USE_OTA_ENCRYPTION +#include "esphome/components/noise/noise.h" +#include "esphome/components/ota/ota_backend.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +#ifdef USE_ESP8266 +#include +#endif + +namespace esphome { + +static const char *const TAG = "esphome.ota"; + +#ifdef USE_ESP8266 +static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit"; +#else +static constexpr char OTA_NOISE_PROLOGUE_INIT[] = "NoiseOTAInit"; +#endif +static constexpr size_t OTA_NOISE_PROLOGUE_INIT_LEN = sizeof(OTA_NOISE_PROLOGUE_INIT) - 1; + +ESPHomeOTAComponent::NoiseSession::~NoiseSession() { + if (this->send_cipher != nullptr) { + noise_cipherstate_free(this->send_cipher); + } + if (this->recv_cipher != nullptr) { + noise_cipherstate_free(this->recv_cipher); + } +} + +/** Allocate the session and start the responder handshake. + * + * The prologue binds the whole plaintext preamble, so any tampering with the + * negotiation (a stripped feature flag, a changed version) breaks the first + * handshake MAC on either side: + * "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags + */ +bool ESPHomeOTAComponent::noise_start_session_(uint8_t server_feature_flags) { + // A provisioned key cleared between the offer and here is not guarded: the + // session runs on the zero key load_psk fills in and fails the client's MAC. + // Default placement, PSRAM first where present: the session only lives for one upload + this->noise_ = RAMAllocator().make_unique(); + static constexpr size_t PROLOGUE_ACK_LEN = 2; // OTA_RESPONSE_OK + version + static constexpr size_t PROLOGUE_CLIENT_FEATURES_LEN = 1; + static constexpr size_t PROLOGUE_FEATURE_ACK_LEN = 2; // OTA_RESPONSE_FEATURE_FLAGS + server flags + uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + sizeof(MAGIC_BYTES) + PROLOGUE_ACK_LEN + PROLOGUE_CLIENT_FEATURES_LEN + + PROLOGUE_FEATURE_ACK_LEN]; + progmem_memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN); + uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN; + // Magic bytes, already validated in MAGIC_READ + std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES)); + p += sizeof(MAGIC_BYTES); + // Our magic ack + *p++ = ota::OTA_RESPONSE_OK; + *p++ = USE_OTA_VERSION; + // The feature byte the client sent + *p++ = this->ota_features_; + // The feature ack we sent (noise requires the extended protocol) + *p++ = ota::OTA_RESPONSE_FEATURE_FLAGS; + *p++ = server_feature_flags; + + // The caller only starts a session when the context holds a key + int err = this->noise_ == nullptr ? NOISE_ERROR_NO_MEMORY + : this->noise_->handshake.init(this->noise_context_(), prologue, sizeof(prologue)); + if (err != 0) { + // Raw noise codes throughout: the name table would cost flash in builds + // where only the OTA uses noise + ESP_LOGW(TAG, "Session init: %d", err); + this->cleanup_connection_(); + return false; + } + return true; +} + +/** Drive the non-blocking handshake from loop(); returns true once the + * transport ciphers are ready. A would-block returns false and the next + * loop() resumes from the NoiseSession cursors; on failure the connection + * is cleaned up. + */ +bool ESPHomeOTAComponent::handle_noise_handshake_() { + NoiseSession &s = *this->noise_; + while (true) { + if (s.writing) { + if (!this->noise_try_write_frame_()) { + return false; // would block, or errored and cleaned up + } + s.writing = false; + s.frame_pos = 0; + s.frame_len = 0; + } + switch (s.handshake.action()) { + case noise::NoiseResponderHandshake::Action::ACTION_READ: { + if (!this->noise_try_read_frame_()) { + return false; + } + const uint16_t payload_len = s.frame_len - noise::FRAME_HEADER_SIZE; + s.frame_pos = 0; + s.frame_len = 0; + if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) { + ESP_LOGW(TAG, "Client rejected the handshake: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]); + this->cleanup_connection_(); + return false; + } + int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1); + if (err != 0) { + // A MAC failure here almost always means the uploader has a different key + const LogString *reason = noise::reject_reason_for(err); + ESP_LOGW(TAG, "Handshake read: %s (%d)", LOG_STR_ARG(reason), err); + this->noise_send_reject_(reason); + this->cleanup_connection_(); + return false; + } + break; + } + case noise::NoiseResponderHandshake::Action::ACTION_WRITE: { + size_t msg_len = 0; + int err = + s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len); + if (err != 0) { + ESP_LOGW(TAG, "Handshake write: %d", err); + this->cleanup_connection_(); + return false; + } + const uint16_t payload_len = msg_len + 1; + noise::write_frame_header(s.frame_buf, payload_len); + s.frame_buf[noise::FRAME_HEADER_SIZE] = noise::HANDSHAKE_STATUS_OK; + s.frame_len = noise::FRAME_HEADER_SIZE + payload_len; + s.frame_pos = 0; + s.writing = true; + break; + } + case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: { + int err = s.handshake.split(s.send_cipher, s.recv_cipher); + if (err != 0) { + ESP_LOGW(TAG, "Handshake split: %d", err); + this->cleanup_connection_(); + return false; + } + ESP_LOGD(TAG, "Noise handshake complete"); + return true; + } + default: { + ESP_LOGW(TAG, "Bad handshake state"); + this->cleanup_connection_(); + return false; + } + } + } +} + +/// Payload length from a frame header, or 0 (logged) when the indicator or +/// the length is out of range. Callers pass min_len >= 1 so 0 is never valid. +size_t ESPHomeOTAComponent::noise_frame_payload_len_(const uint8_t *header, size_t min_len, size_t max_len) { + const size_t payload_len = encode_uint16(header[1], header[2]); + if (header[0] != noise::FRAME_INDICATOR || payload_len < min_len || payload_len > max_len) { + ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], payload_len); + return 0; + } + return payload_len; +} + +/// Non-blocking read of one handshake frame into the session buffer. +bool ESPHomeOTAComponent::noise_try_read_frame_() { + NoiseSession &s = *this->noise_; + while (true) { + // The header first, then the body once the header says how long it is + const uint16_t want = s.frame_len == 0 ? noise::FRAME_HEADER_SIZE : s.frame_len; + if (s.frame_pos < want) { + ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, want - s.frame_pos); + if (!this->handle_read_error_(read, LOG_STR("read noise"))) { + return false; + } + s.frame_pos += read; + continue; + } + if (s.frame_len != 0) { + return true; + } + const size_t payload_len = this->noise_frame_payload_len_(s.frame_buf, 1, 1 + noise::MAX_HANDSHAKE_SIZE); + if (payload_len == 0) { + this->cleanup_connection_(); + return false; + } + s.frame_len = noise::FRAME_HEADER_SIZE + payload_len; + } +} + +/// Non-blocking write of the pending session-buffer frame. +bool ESPHomeOTAComponent::noise_try_write_frame_() { + NoiseSession &s = *this->noise_; + while (s.frame_pos < s.frame_len) { + ssize_t written = this->client_->write(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos); + if (!this->handle_write_error_(written, LOG_STR("write noise frame"))) { + return false; + } + s.frame_pos += written; + } + return true; +} + +/// Best-effort explicit reject frame so the client can log a readable reason. +void ESPHomeOTAComponent::noise_send_reject_(const LogString *reason) { + // Every reason here comes from noise::reject_reason_for(), so the exported + // floor is the exact capacity needed + uint8_t data[noise::FRAME_HEADER_SIZE + noise::MAC_FAILURE_PAYLOAD_SIZE]; + const size_t payload_len = + noise::format_reject_payload(data + noise::FRAME_HEADER_SIZE, sizeof(data) - noise::FRAME_HEADER_SIZE, reason); + noise::write_frame_header(data, payload_len); + this->client_->write(data, noise::FRAME_HEADER_SIZE + payload_len); // Best effort, non-blocking +} + +/// Decrypt a ciphertext in place; returns the plaintext size or -1. +ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, buf, len, len); + int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf); + if (err != 0) { + ESP_LOGW(TAG, "Decrypt: %d", err); + return -1; + } + return mbuf.size; +} + +/** Blocking read of one frame whose ciphertext size must be within the given + * bounds, decrypted in place; returns the plaintext size, or -1 on error. + * buf needs max_ciphertext capacity. + */ +ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext) { + uint8_t header[noise::FRAME_HEADER_SIZE]; + if (!this->readall_(header, sizeof(header))) { + return -1; + } + const size_t ciphertext_len = this->noise_frame_payload_len_(header, min_ciphertext, max_ciphertext); + if (ciphertext_len == 0) { + return -1; + } + if (!this->readall_(buf, ciphertext_len)) { + return -1; + } + return this->noise_decrypt_(buf, ciphertext_len); +} + +/** Blocking read of one frame whose plaintext must be exactly len bytes + * (control units are one unit per frame). buf needs len + noise::MAC_SIZE + * capacity; the plaintext lands at buf[0..len). + */ +bool ESPHomeOTAComponent::noise_readall_(uint8_t *buf, size_t len) { + return this->noise_read_frame_blocking_(buf, len + noise::MAC_SIZE, len + noise::MAC_SIZE) == (ssize_t) len; +} + +/** Blocking read of one data-phase frame, decrypted in place; returns the + * plaintext size, or -1 on error. buf is the OTA_BUFFER_SIZE data buffer. + * The ciphertext must fit that buffer and its plaintext must fit what the + * caller accepts (the remaining image bytes). + */ +ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) { + const size_t max_ciphertext = std::min(capacity + noise::MAC_SIZE, OTA_BUFFER_SIZE); + return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext); +} + +/// Blocking write of one response byte as an encrypted frame. +bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) { + uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE]; + frame[noise::FRAME_HEADER_SIZE] = byte; + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE); + int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf); + if (err != 0) { + ESP_LOGW(TAG, "Encrypt: %d", err); + return false; + } + noise::write_frame_header(frame, mbuf.size); + return this->writeall_(frame, noise::FRAME_HEADER_SIZE + mbuf.size); +} + +} // namespace esphome +#endif // USE_OTA_ENCRYPTION +#endif // USE_OTA diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index c6c90ed67ab..14d099ec062 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -1,6 +1,9 @@ +from typing import Any + from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi +from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant from esphome.components.udp import CONF_ON_RECEIVE import esphome.config_validation as cv from esphome.const import ( @@ -13,7 +16,9 @@ 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 +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] @@ -78,7 +83,7 @@ CONF_CONTINUE_ON_ERROR = "continue_on_error" CONF_WAIT_FOR_SENT = "wait_for_sent" -def _validate_max_payload_size(value: int) -> int: +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), @@ -88,7 +93,7 @@ def _validate_max_payload_size(value: int) -> int: 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) @@ -129,7 +134,25 @@ CONFIG_SCHEMA = cv.All( ) -async def _trigger_to_code(config): +def _validate_variant(config: ConfigType) -> ConfigType: + # ESP-NOW rides the Wi-Fi PHY. Radio-less esp32 variants have no native + # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that + # supplies the esp_now_* symbols. Fail here with a clear message instead of + # letting the build reach an "undefined reference to esp_now_*" link error. + variant = get_esp32_variant() + if wifi.variant_has_wifi(variant): + return config + if variant != VARIANT_ESP32P4: + raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)") + if "esp32_hosted" not in fv.full_config.get(): + raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component") + return config + + +FINAL_VALIDATE_SCHEMA = _validate_variant + + +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) @@ -145,12 +168,21 @@ async def _trigger_to_code(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 CORE.is_esp32: + from esphome.components.esp32 import include_builtin_idf_component + + include_builtin_idf_component("esp_wifi") + + 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)) @@ -176,13 +208,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( @@ -200,7 +232,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] @@ -227,7 +261,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.", @@ -263,7 +297,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]) @@ -312,7 +346,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) @@ -337,7 +371,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/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index f28d7f33544..ecf0f79e4a7 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -7,7 +7,6 @@ #include #include -#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -75,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; } @@ -90,8 +90,8 @@ 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) { @@ -101,6 +101,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int // 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; } @@ -109,6 +110,7 @@ void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int 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; } @@ -120,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, @@ -156,6 +161,11 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { +#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 { @@ -163,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; @@ -254,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) { @@ -348,6 +362,15 @@ void ESPNowComponent::loop() { if (send_dropped > 0) { 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(); + } } uint8_t ESPNowComponent::get_wifi_channel() { @@ -390,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 d95255c5df7..af693b47cfc 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 @@ -88,7 +93,11 @@ class ESPNowBroadcastHandler { virtual bool on_broadcast(const ESPNowRecvInfo &info, const uint8_t *data, uint16_t size) = 0; }; +#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 final : 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/packet_transport/__init__.py b/esphome/components/espnow/packet_transport/__init__.py index e6d66440db1..ee4706ca1c4 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/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index ad63c0d13d8..3e7d345805c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -4,13 +4,17 @@ import logging from esphome import automation, pins from esphome.automation import Condition import esphome.codegen as cg +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_platform +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, @@ -36,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, @@ -48,10 +53,12 @@ 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 @@ -132,6 +139,7 @@ ETHERNET_TYPES = { "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 @@ -153,6 +161,7 @@ _PHY_TYPE_TO_DEFINE = { "W6300": "USE_ETHERNET_W6300", "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", + "CH390": "USE_ETHERNET_CH390", } @@ -176,13 +185,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { "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"} +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. @@ -255,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 @@ -273,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]) @@ -352,7 +394,7 @@ def _validate(config): " clk:\n" " mode: %s\n" " pin: %s\n" - "Removal scheduled for 2026.9.0.", + "Removal scheduled for 2026.11.0.", config[CONF_CLK_MODE], mode, pin, @@ -378,7 +420,9 @@ def _validate(config): BASE_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(EthernetComponent), - cv.Optional(CONF_MANUAL_IP): MANUAL_IP_SCHEMA, + cv.Optional( + CONF_MANUAL_IP, visibility=cv.Visibility.ADVANCED + ): MANUAL_IP_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, @@ -437,37 +481,60 @@ GENERIC_SCHEMA = cv.All( cv.only_on([Platform.ESP32]), ) -SPI_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)), - ), - } + +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_interface, -) + 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( @@ -483,7 +550,8 @@ CONFIG_SCHEMA = cv.All( "W5500": SPI_SCHEMA, "OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])), "DM9051": SPI_SCHEMA, - "ENC28J60": SPI_SCHEMA, + "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, @@ -496,13 +564,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" @@ -516,7 +608,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])), @@ -527,7 +619,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), @@ -537,7 +629,7 @@ 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 @@ -589,7 +681,7 @@ 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, @@ -599,9 +691,15 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) 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])) @@ -615,11 +713,13 @@ 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 ) @@ -674,7 +774,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])) @@ -743,7 +843,7 @@ 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(): @@ -763,14 +863,13 @@ def _final_validate(config: ConfigType) -> ConfigType: _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") @@ -788,12 +887,23 @@ _platform_filter = filter_source_files_from_platform( 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 @@ -807,13 +917,19 @@ 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, id_, template_arg, args): +async def _new_pvariable_to_code( + config: ConfigType, + id_: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: return cg.new_Pvariable(id_, template_arg) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 42cb0b3cfc0..14a4fd660bb 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 9f4398c621f..2b67b9093b7 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" @@ -88,6 +91,7 @@ enum EthernetType : uint8_t { ETHERNET_TYPE_W6300, ETHERNET_TYPE_GENERIC, ETHERNET_TYPE_YT8531, + ETHERNET_TYPE_CH390, }; struct ManualIP { @@ -124,7 +128,7 @@ 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; } @@ -139,11 +143,17 @@ class EthernetComponent final : public Component { bool is_disabled() { return this->disabled_; } bool is_enabled() { return !this->disabled_; } - void set_type(EthernetType type); -#ifdef USE_ETHERNET_MANUAL_IP - void set_manual_ip(const ManualIP &manual_ip); +#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); @@ -152,9 +162,6 @@ class EthernetComponent final : public Component { 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(); @@ -164,35 +171,38 @@ 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_RP2 - void set_clk_pin(uint8_t clk_pin); - void set_miso_pin(uint8_t miso_pin); - void set_mosi_pin(uint8_t mosi_pin); - void set_cs_pin(uint8_t cs_pin); - void set_interrupt_pin(int8_t interrupt_pin); - void set_reset_pin(int8_t reset_pin); + 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 @@ -254,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 @@ -335,7 +350,7 @@ class EthernetComponent final : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - optional> fixed_mac_; + optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS StaticVector ip_state_listeners_; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 5ad1e7d483c..1d9903271ec 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -50,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 { @@ -162,25 +171,34 @@ void EthernetComponent::ethernet_lazy_init_() { // 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, - }; + 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, + }; - auto host = this->interface_; + host = this->interface_; - err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO); - ESPHL_ERROR_CHECK(err, "SPI bus initialize error"); + 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 @@ -215,6 +233,8 @@ void EthernetComponent::ethernet_lazy_init_() { 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) @@ -232,8 +252,15 @@ void EthernetComponent::ethernet_lazy_init_() { 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_; @@ -358,6 +385,12 @@ void EthernetComponent::ethernet_lazy_init_() { 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: { @@ -408,9 +441,9 @@ void EthernetComponent::ethernet_lazy_init_() { #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); } @@ -517,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: @@ -550,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_); @@ -764,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 @@ -874,25 +928,7 @@ 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 @@ -901,7 +937,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { // 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(), 6); + memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac, ESP_MAC_ETH); } @@ -912,14 +948,9 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *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(); diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 4d6d6c4f5b2..94d84cc8914 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -245,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(); @@ -355,13 +350,6 @@ 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 diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index ed4f149738f..9c6b59582a3 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -6,17 +6,21 @@ #include #include #include -#include namespace esphome::ethernet { namespace { -// Per-device context returned by init() and handed back to read/write/deinit. +// Context returned by init() and handed back to read/write/deinit. There is one W5500 per device, so a +// single static instance replaces a heap allocation that could fail. It is always clear when init() runs: +// esp_eth_mac_new_w5500() calls deinit() on every failure after init() succeeded, and nothing else +// uninstalls the driver struct W5500CustomSpiContext { spi_device_handle_t handle; SemaphoreHandle_t lock; }; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - intentional mutable state +W5500CustomSpiContext w5500_context{}; // 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. @@ -25,23 +29,20 @@ 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; - } + auto *ctx = &w5500_context; // 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; + ctx->handle = nullptr; return nullptr; } ctx->lock = xSemaphoreCreateMutex(); if (ctx->lock == nullptr) { spi_bus_remove_device(ctx->handle); - delete ctx; + ctx->handle = nullptr; return nullptr; } return ctx; @@ -51,7 +52,7 @@ 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; + *ctx = {}; return ESP_OK; } diff --git a/esphome/components/ethernet_info/text_sensor.py b/esphome/components/ethernet_info/text_sensor.py index 8c20cf332c9..66483cdb858 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 e205e4b9109..881107b713a 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 @@ -93,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)) @@ -108,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) @@ -116,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 @@ -133,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) @@ -142,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/exposure_notifications/__init__.py b/esphome/components/exposure_notifications/__init__.py index ab7416a2641..4f7e698e23b 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 e7038d2ca9e..4f4b93b59cc 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 6a703a9a921..dc1241db562 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 { @@ -17,11 +15,9 @@ struct ExposureNotification { }; class ExposureNotificationTrigger final : public Trigger, - public esp32_ble_tracker::ESPBTDeviceListener { + 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 c892ec11123..504b1ae6794 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: @@ -84,8 +111,8 @@ def _process_single_config(config: dict[str, Any]) -> None: 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/sensor.py b/esphome/components/ezo/sensor.py index b931885149f..d1ee57a09b7 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/sensor.py b/esphome/components/ezo_pmp/sensor.py index a0473b292cf..ed4efeeabcb 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 818a53c0ed2..a9064eb18fd 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 61df5f297b7..040614c151a 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/switch/__init__.py b/esphome/components/factory_reset/switch/__init__.py index a384a57f80e..69a635a9170 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/fan/fan.cpp b/esphome/components/fan/fan.cpp index 853bf94ffe4..65521e63d51 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); } @@ -339,8 +334,9 @@ void Fan::dump_traits_(const char *tag, const char *prefix) { } if (traits.supports_preset_modes()) { ESP_LOGCONFIG(tag, "%s Supported presets:", prefix); - for (const char *s : traits.supported_preset_modes()) + for (const char *s : traits.supported_preset_modes()) { ESP_LOGCONFIG(tag, "%s - %s", prefix, s); + } } } diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 3d731e6eb08..106e6e74cd8 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 a26a235da73..e2fc8578cde 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,7 +36,7 @@ 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) diff --git a/esphome/components/fastled_clockless/light.py b/esphome/components/fastled_clockless/light.py index aa2172bf884..56c1ee93fad 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 e863d338461..1c6b6e7148f 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 856818280f1..032d01c8e51 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.cpp b/esphome/components/feedback/feedback_cover.cpp index 1139e6fa189..4baffc74f83 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -93,7 +93,8 @@ void FeedbackCover::set_open_sensor(binary_sensor::BinarySensor *open_feedback) // setup callbacks to react to sensor changes open_feedback->add_on_state_callback([this](bool state) { - ESP_LOGD(TAG, "'%s' - Open feedback '%s'.", this->name_.c_str(), state ? "STARTED" : "ENDED"); + ESP_LOGD(TAG, "'%s' - Open feedback '%s'.", this->name_.c_str(), + state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED")); this->recompute_position_(); if (!state && this->infer_endstop_ && this->current_trigger_operation_ == COVER_OPERATION_OPENING) { this->endstop_reached_(true); @@ -106,7 +107,8 @@ void FeedbackCover::set_close_sensor(binary_sensor::BinarySensor *close_feedback this->close_feedback_ = close_feedback; close_feedback->add_on_state_callback([this](bool state) { - ESP_LOGD(TAG, "'%s' - Close feedback '%s'.", this->name_.c_str(), state ? "STARTED" : "ENDED"); + ESP_LOGD(TAG, "'%s' - Close feedback '%s'.", this->name_.c_str(), + state ? LOG_STR_LITERAL("STARTED") : LOG_STR_LITERAL("ENDED")); this->recompute_position_(); if (!state && this->infer_endstop_ && this->current_trigger_operation_ == COVER_OPERATION_CLOSING) { this->endstop_reached_(false); @@ -144,7 +146,8 @@ void FeedbackCover::endstop_reached_(bool open_endstop) { // from a position slightly past the endpoint if (this->current_trigger_operation_ == (open_endstop ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING)) { float dur = (now - this->start_dir_time_) / 1e3f; - ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", this->name_.c_str(), open_endstop ? "Open" : "Close", dur); + ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", this->name_.c_str(), + open_endstop ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"), dur); // if there is no external mechanism, stop the cover if (!this->has_built_in_endstop_) { @@ -366,7 +369,7 @@ void FeedbackCover::start_direction_(CoverOperation dir) { // the case when an obstacle appears while moving is handled in the callback if (obstacle != nullptr && obstacle->state) { ESP_LOGD(TAG, "'%s' - %s obstacle detected. Action not started.", this->name_.c_str(), - dir == COVER_OPERATION_OPENING ? "Open" : "Close"); + dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close")); return; } #endif @@ -383,9 +386,9 @@ void FeedbackCover::start_direction_(CoverOperation dir) { this->set_current_operation_(dir, true); this->prev_command_trigger_ = trig; ESP_LOGD(TAG, "'%s' - Firing '%s' trigger.", this->name_.c_str(), - dir == COVER_OPERATION_OPENING ? "OPEN" - : dir == COVER_OPERATION_CLOSING ? "CLOSE" - : "STOP"); + dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("OPEN") + : dir == COVER_OPERATION_CLOSING ? LOG_STR_LITERAL("CLOSE") + : LOG_STR_LITERAL("STOP")); trig->trigger(); } } diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79e..7cef7c754a4 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,11 +1,11 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path import re +from typing import Any from PIL import Image, UnidentifiedImageError @@ -24,6 +24,7 @@ from esphome.components.image import ( get_image_type_enum, get_transparency_enum, is_svg_file, + validate_byte_order, validate_settings, validate_transparency, validate_type, @@ -43,15 +44,13 @@ from esphome.const import ( ) 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__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,53 +64,93 @@ MDI_SOURCES = { 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) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) -def local_path(value): +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, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) +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 download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + 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): +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 validate_file_shorthand(value): - value = cv.string_strict(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: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + 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) @@ -125,8 +164,8 @@ LOCAL_SCHEMA = cv.All( ) -def mdi_schema(source): - def validate_mdi(value): +def mdi_schema(source: str) -> cv.All: + def validate_mdi(value: ConfigType) -> str: return download_gh_svg(value, source) return cv.All( @@ -163,7 +202,7 @@ OPTIONS_SCHEMA = { "NONE", "FLOYDSTEINBERG", upper=True ), cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean, - cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True), + cv.Optional(CONF_BYTE_ORDER): validate_byte_order, cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(), } @@ -188,7 +227,7 @@ def image_schema(class_: MockObjClass = Image_) -> cv.Schema: ) -def validate_image_final(config: ConfigType) -> ConfigType: +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 @@ -203,7 +242,6 @@ def validate_image_final(config: ConfigType) -> ConfigType: ) else: config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN" - return config async def new_image(config: ConfigType) -> MockObj: @@ -222,7 +260,9 @@ async def new_image(config: ConfigType) -> MockObj: return var -async def write_image(config, all_frames=False): +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}") diff --git a/esphome/components/fingerprint_grow/fingerprint_grow.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index b38d42191bc..07630f121ac 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -547,8 +547,8 @@ void FingerprintGrowComponent::dump_config() { " System Identifier Code: 0x%.4X\n" " Touch Sensing Pin: %s\n" " Sensor Power Pin: %s", - this->system_identifier_code_, this->has_sensing_pin_ ? sensing_pin_buf : "None", - this->has_power_pin_ ? power_pin_buf : "None"); + this->system_identifier_code_, this->has_sensing_pin_ ? sensing_pin_buf : LOG_STR_LITERAL("None"), + this->has_power_pin_ ? power_pin_buf : LOG_STR_LITERAL("None")); if (this->idle_period_to_sleep_ms_ < UINT32_MAX) { ESP_LOGCONFIG(TAG, " Idle Period to Sleep: %" PRIu32 " ms", this->idle_period_to_sleep_ms_); } else { diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7510f2f8b69..918fde5dbdd 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://", "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( diff --git a/esphome/components/fs3000/sensor.py b/esphome/components/fs3000/sensor.py index a168a36c31a..8c389a25935 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 e94791da4e8..3ebff693c00 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/ft63x6/touchscreen.py b/esphome/components/ft63x6/touchscreen.py index 7615b3046fb..0d8537bde90 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 a104eafbcc4..c2c41730e3b 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/gcja5/sensor.py b/esphome/components/gcja5/sensor.py index e4de7721c65..49907443ff9 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 878f27bc446..f98af3f8634 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 a80487977fa..14f5fa0e1ce 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/sensor.py b/esphome/components/gdk101/sensor.py index 6cf89e0fd4c..4ed081a7bef 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 703e68493af..bdef2466dfa 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/sensor.py b/esphome/components/gl_r01_i2c/sensor.py index 6a8d47213cb..73f7339e666 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 46725fe6ddd..bd6bc5f783a 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/gp2y1010au0f/sensor.py b/esphome/components/gp2y1010au0f/sensor.py index 4ff8a382269..3121aa1de50 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 83859a40302..17c88b68750 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/output/__init__.py b/esphome/components/gp8403/output/__init__.py index 5245c405db6..432f387b1b8 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/gpio/binary_sensor/__init__.py b/esphome/components/gpio/binary_sensor/__init__.py index 43358baedba..8a40e4e7325 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 @@ -68,10 +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 + return # Expander pins (e.g. PCF8574, MCP23017) don't support direct interrupt # attachment — only internal/native GPIO pins do. @@ -82,7 +83,7 @@ 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] @@ -96,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. @@ -120,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) @@ -134,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 ff07d76901f..9d044dca2d0 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 100edb4cca4..956443fab55 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 e2bb94dd666..feb8b53dff6 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 1fecfbf0dd1..f445efeca37 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/output/__init__.py b/esphome/components/gpio/output/__init__.py index 786e04bac04..ab242c643fc 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/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 9462cd01615..766cdc4afb3 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 @@ -14,9 +15,13 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_INTERLOCK): cv.ensure_list(cv.use_id(switch.Switch)), cv.Optional( - CONF_INTERLOCK_WAIT_TIME, default="0ms" + CONF_INTERLOCK, visibility=cv.Visibility.ADVANCED + ): cv.ensure_list(cv.use_id(switch.Switch)), + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, + default="0ms", + visibility=cv.Visibility.ADVANCED, ): cv.positive_time_period_milliseconds, } ) @@ -24,7 +29,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 e69de29bb2d..0c7199b6dfb 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 ab48417a4e7..94a36a0afa0 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/time/__init__.py b/esphome/components/gps/time/__init__.py index bdeeb86e007..faa06e6ae3a 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/graph/__init__.py b/esphome/components/graph/__init__.py index 0749d7e2a3f..1b99491f9c5 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/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index 56b720e75c6..668a0d74d14 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 b3c3b27e066..f0642d2e8c7 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -35,18 +35,20 @@ void GraphicalDisplayMenu::setup() { } void GraphicalDisplayMenu::dump_config() { - ESP_LOGCONFIG(TAG, - "Graphical Display Menu\n" - " Has Display: %s\n" - " Popup Mode: %s\n" - " Advanced Drawing Mode: %s\n" - " Has Font: %s\n" - " Mode: %s\n" - " Active: %s\n" - " Menu items:", - YESNO(this->display_ != nullptr), YESNO(this->display_ != nullptr), YESNO(this->display_ == nullptr), - YESNO(this->font_ != nullptr), - this->mode_ == display_menu_base::MENU_MODE_ROTARY ? "Rotary" : "Joystick", YESNO(this->active_)); + ESP_LOGCONFIG( + TAG, + "Graphical Display Menu\n" + " Has Display: %s\n" + " Popup Mode: %s\n" + " Advanced Drawing Mode: %s\n" + " Has Font: %s\n" + " Mode: %s\n" + " Active: %s\n" + " Menu items:", + YESNO(this->display_ != nullptr), YESNO(this->display_ != nullptr), YESNO(this->display_ == nullptr), + YESNO(this->font_ != nullptr), + this->mode_ == display_menu_base::MENU_MODE_ROTARY ? LOG_STR_LITERAL("Rotary") : LOG_STR_LITERAL("Joystick"), + YESNO(this->active_)); for (size_t i = 0; i < this->displayed_item_->items_size(); i++) { auto *item = this->displayed_item_->get_item(i); ESP_LOGCONFIG(TAG, " %i: %s (Type: %s, Immediate Edit: %s)", i, item->get_text().c_str(), diff --git a/esphome/components/gree/climate.py b/esphome/components/gree/climate.py index 0892155fd22..356845a7a6f 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/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 111fea65d27..9bec3751d6b 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/grove_gas_mc_v2/sensor.py b/esphome/components/grove_gas_mc_v2/sensor.py index 0c350478504..da687c4cd34 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/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 6485e90c25a..08c3966ed9b 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -1,106 +1,68 @@ #include "growatt_solar.h" -#include "esphome/core/application.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::growatt_solar { +namespace helpers = modbus::helpers; + 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() { - // If update() was unable to send we retry until we can send. - if (!this->waiting_to_update_) - return; - update(); -} +void GrowattSolar::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); } -void GrowattSolar::update() { - // If our last send has had no reply yet, and it wasn't that long ago, do nothing. - const uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_send_ < this->get_update_interval() / 2) { - return; - } - - // The bus might be slow, or there might be other devices, or other components might be talking to our device. - if (!this->ready_for_immediate_send()) { - this->waiting_to_update_ = true; - return; - } - - this->waiting_to_update_ = false; - this->send(MODBUS_CMD_READ_IN_REGISTERS, 0, MODBUS_REGISTER_COUNT[this->protocol_version_]); - this->last_send_ = millis(); -} - -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_) - return; - this->last_send_ = 0; - - // Also ignore the data if the message is too short. Otherwise we will publish invalid values. - if (data.size() < MODBUS_REGISTER_COUNT[this->protocol_version_] * 2) +void GrowattSolar::on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) return; - auto publish_1_reg_sensor_state = [&](sensor::Sensor *sensor, size_t i, float unit) -> void { + // Publish a sensor if its register(s) are in this response; skipping absent registers keeps this + // correct for any read range, so the poll may be split into multiple requests. + auto publish_1_reg_sensor_state = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { if (sensor == nullptr) return; - float value = encode_uint16(data[i * 2], data[i * 2 + 1]) * unit; - sensor->publish_state(value); + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; - auto publish_2_reg_sensor_state = [&](sensor::Sensor *sensor, size_t reg1, size_t reg2, float unit) -> void { - float value = ((encode_uint16(data[reg1 * 2], data[reg1 * 2 + 1]) << 16) + - encode_uint16(data[reg2 * 2], data[reg2 * 2 + 1])) * - unit; - if (sensor != nullptr) - sensor->publish_state(value); + auto publish_2_reg_sensor_state = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; switch (this->protocol_version_) { case RTU: { publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, 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_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, 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->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, ONE_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_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, 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_, 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_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, 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_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, 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->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, 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_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; @@ -108,42 +70,33 @@ void GrowattSolar::on_response(std::span request_pdu, std::spaninverter_status_, RTU2_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, 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_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, 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->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, ONE_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_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, 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_, 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_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, 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_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, 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->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, 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_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, 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 a172f49001e..5b965214762 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -17,59 +17,59 @@ enum GrowattProtocolVersion { }; // 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 +constexpr uint16_t RTU_INVERTER_STATUS = 0; // length = 1 +constexpr uint16_t RTU_PV_ACTIVE_POWER = 1; // length = 2 +constexpr uint16_t RTU_PV1_VOLTAGE = 3; // length = 1 +constexpr uint16_t RTU_PV1_CURRENT = 4; // length = 1 +constexpr uint16_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr uint16_t RTU_PV2_VOLTAGE = 7; // length = 1 +constexpr uint16_t RTU_PV2_CURRENT = 8; // length = 1 +constexpr uint16_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr uint16_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 +constexpr uint16_t RTU_GRID_FREQUENCY = 13; // length = 1 +constexpr uint16_t RTU_PHASE1_VOLTAGE = 14; // length = 1 +constexpr uint16_t RTU_PHASE1_CURRENT = 15; // length = 1 +constexpr uint16_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 +constexpr uint16_t RTU_PHASE2_VOLTAGE = 18; // length = 1 +constexpr uint16_t RTU_PHASE2_CURRENT = 19; // length = 1 +constexpr uint16_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 +constexpr uint16_t RTU_PHASE3_VOLTAGE = 22; // length = 1 +constexpr uint16_t RTU_PHASE3_CURRENT = 23; // length = 1 +constexpr uint16_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 +constexpr uint16_t RTU_TODAY_PRODUCTION = 26; // length = 2 +constexpr uint16_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 +constexpr uint16_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 +constexpr uint16_t RTU2_INVERTER_STATUS = 0; // length = 1 +constexpr uint16_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 +constexpr uint16_t RTU2_PV1_VOLTAGE = 3; // length = 1 +constexpr uint16_t RTU2_PV1_CURRENT = 4; // length = 1 +constexpr uint16_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr uint16_t RTU2_PV2_VOLTAGE = 7; // length = 1 +constexpr uint16_t RTU2_PV2_CURRENT = 8; // length = 1 +constexpr uint16_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr uint16_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 +constexpr uint16_t RTU2_GRID_FREQUENCY = 37; // length = 1 +constexpr uint16_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 +constexpr uint16_t RTU2_PHASE1_CURRENT = 39; // length = 1 +constexpr uint16_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 +constexpr uint16_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 +constexpr uint16_t RTU2_PHASE2_CURRENT = 43; // length = 1 +constexpr uint16_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 +constexpr uint16_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 +constexpr uint16_t RTU2_PHASE3_CURRENT = 47; // length = 1 +constexpr uint16_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 +constexpr uint16_t RTU2_TODAY_PRODUCTION = 53; // length = 2 +constexpr uint16_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 +constexpr uint16_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_response(std::span request_pdu, std::span response_pdu) override; + void on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; } @@ -104,9 +104,6 @@ class GrowattSolar final : public PollingComponent, public modbus::ModbusClientD } protected: - bool waiting_to_update_{false}; - uint32_t last_send_{0}; - struct GrowattPhase { sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index d1f00693413..2e2b2187307 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -163,14 +163,14 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("growatt_solar", role="client")(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): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index fc0318f076a..703887864bd 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -156,7 +157,24 @@ FIRMWARE_SCHEMA = cv.All( ) -def _config_schema(config): +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) } @@ -188,7 +206,7 @@ def _config_schema(config): CONFIG_SCHEMA = _config_schema -def _read_firmware(config) -> bytes: +def _read_firmware(config: ConfigType) -> bytes: path = firmware_path(config[CONF_FIRMWARE]) data = path.read_bytes() LOGGER.info( @@ -203,7 +221,7 @@ def _read_firmware(config) -> bytes: # --------------------------------------------------------------------------- # Code generation # --------------------------------------------------------------------------- -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/binary_sensor/__init__.py b/esphome/components/gt911/binary_sensor/__init__.py index 941b7bb847b..95c072977e6 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/touchscreen/__init__.py b/esphome/components/gt911/touchscreen/__init__.py index b850eeea8bc..fa929d4ba0d 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.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 2152ae7b842..8ced2679474 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -69,10 +69,12 @@ void GT911Touchscreen::setup_internal_() { // Direct MCU pin: attach a hardware interrupt, no polling needed. this->attach_interrupt_(static_cast(this->interrupt_pin_), active_high ? gpio::INTERRUPT_RISING_EDGE : gpio::INTERRUPT_FALLING_EDGE); - ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s", active_high ? "HIGH" : "LOW"); + ESP_LOGD(TAG, "Interrupt pin: hardware interrupt, active %s", + active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } else { // IO expander pin: leave as output for configuration only. - ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s", active_high ? "HIGH" : "LOW"); + ESP_LOGD(TAG, "Interrupt pin: IO expander polling mode, active %s", + active_high ? LOG_STR_LITERAL("HIGH") : LOG_STR_LITERAL("LOW")); } } } diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index 424ef46392e..70ae36f5281 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 294aa53b035..48f72dc16b9 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -248,7 +248,8 @@ void HaierClimateBase::setup() { void HaierClimateBase::dump_config() { LOG_CLIMATE("", "Haier Climate", this); - ESP_LOGCONFIG(TAG, " Device communication status: %s", this->valid_connection() ? "established" : "none"); + ESP_LOGCONFIG(TAG, " Device communication status: %s", + this->valid_connection() ? LOG_STR_LITERAL("established") : LOG_STR_LITERAL("none")); } void HaierClimateBase::loop() { diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 881a2328cb3..0ce4142fd43 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -343,11 +343,11 @@ void HonClimate::dump_config() { this->hvac_hardware_info_.value().software_version_, this->hvac_hardware_info_.value().hardware_version_, this->hvac_hardware_info_.value().device_name_); ESP_LOGCONFIG(TAG, " Device features:%s%s%s%s%s", - (this->hvac_hardware_info_.value().functions_[0] ? " interactive" : ""), - (this->hvac_hardware_info_.value().functions_[1] ? " controller-device" : ""), - (this->hvac_hardware_info_.value().functions_[2] ? " crc" : ""), - (this->hvac_hardware_info_.value().functions_[3] ? " multinode" : ""), - (this->hvac_hardware_info_.value().functions_[4] ? " role" : "")); + (this->hvac_hardware_info_.value().functions_[0] ? LOG_STR_LITERAL(" interactive") : ""), + (this->hvac_hardware_info_.value().functions_[1] ? LOG_STR_LITERAL(" controller-device") : ""), + (this->hvac_hardware_info_.value().functions_[2] ? LOG_STR_LITERAL(" crc") : ""), + (this->hvac_hardware_info_.value().functions_[3] ? LOG_STR_LITERAL(" multinode") : ""), + (this->hvac_hardware_info_.value().functions_[4] ? LOG_STR_LITERAL(" role") : "")); ESP_LOGCONFIG(TAG, " Active alarms: %s", buf_to_hex(this->active_alarms_, sizeof(this->active_alarms_)).c_str()); } } diff --git a/esphome/components/haier/switch/__init__.py b/esphome/components/haier/switch/__init__.py index acff0cf265c..99ffcb37af5 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/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index 45e57544db0..d43dfbb89aa 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -1,128 +1,74 @@ #include "havells_solar.h" #include "havells_solar_registers.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::havells_solar { +namespace helpers = modbus::helpers; + 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_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; - } +void HavellsSolar::on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) + return; // the hub already logs exception responses - /* Usage: returns the float value of 1 register read by modbus - Arg1: Register address * number of bytes per register - Arg2: Multiplier for final register value - */ - auto havells_solar_get_2_registers = [&](size_t i, float unit) -> float { - uint32_t temp = encode_uint32(data[i], data[i + 1], data[i + 2], data[i + 3]); - return temp * unit; + // Publish a sensor if its register(s) are in this response; skipping absent registers keeps this + // correct for any read range, so the poll may be split into multiple requests. + auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; - /* Usage: returns the float value of 2 registers read by modbus - Arg1: Register address * number of bytes per register - Arg2: Multiplier for final register value - */ - auto havells_solar_get_1_register = [&](size_t i, float unit) -> float { - uint16_t temp = encode_uint16(data[i], data[i + 1]); - return temp * unit; + auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; for (uint8_t i = 0; i < 3; i++) { - auto phase = this->phases_[i]; + auto &phase = this->phases_[i]; if (!phase.setup) continue; - - float voltage = havells_solar_get_1_register(HAVELLS_PHASE_1_VOLTAGE * 2 + (i * 4), ONE_DEC_UNIT); - float current = havells_solar_get_1_register(HAVELLS_PHASE_1_CURRENT * 2 + (i * 4), TWO_DEC_UNIT); - - if (phase.voltage_sensor_ != nullptr) - phase.voltage_sensor_->publish_state(voltage); - if (phase.current_sensor_ != nullptr) - phase.current_sensor_->publish_state(current); + publish_1_register(phase.voltage_sensor_, HAVELLS_PHASE_1_VOLTAGE + i * 2, ONE_DEC_UNIT); + publish_1_register(phase.current_sensor_, HAVELLS_PHASE_1_CURRENT + i * 2, TWO_DEC_UNIT); } for (uint8_t i = 0; i < 2; i++) { - auto pv = this->pvs_[i]; + auto &pv = this->pvs_[i]; if (!pv.setup) continue; - - float voltage = havells_solar_get_1_register(HAVELLS_PV_1_VOLTAGE * 2 + (i * 4), ONE_DEC_UNIT); - float current = havells_solar_get_1_register(HAVELLS_PV_1_CURRENT * 2 + (i * 4), TWO_DEC_UNIT); - float active_power = havells_solar_get_1_register(HAVELLS_PV_1_POWER * 2 + (i * 2), MULTIPLY_TEN_UNIT); - float voltage_sampled_by_secondary_cpu = - havells_solar_get_1_register(HAVELLS_PV1_VOLTAGE_SAMPLED_BY_SECONDARY_CPU * 2 + (i * 2), ONE_DEC_UNIT); - float insulation_of_p_to_ground = - havells_solar_get_1_register(HAVELLS_PV1_INSULATION_OF_P_TO_GROUND * 2 + (i * 2), NO_DEC_UNIT); - - if (pv.voltage_sensor_ != nullptr) - pv.voltage_sensor_->publish_state(voltage); - if (pv.current_sensor_ != nullptr) - pv.current_sensor_->publish_state(current); - if (pv.active_power_sensor_ != nullptr) - pv.active_power_sensor_->publish_state(active_power); - if (pv.voltage_sampled_by_secondary_cpu_sensor_ != nullptr) - pv.voltage_sampled_by_secondary_cpu_sensor_->publish_state(voltage_sampled_by_secondary_cpu); - if (pv.insulation_of_p_to_ground_sensor_ != nullptr) - pv.insulation_of_p_to_ground_sensor_->publish_state(insulation_of_p_to_ground); + publish_1_register(pv.voltage_sensor_, HAVELLS_PV_1_VOLTAGE + i * 2, ONE_DEC_UNIT); + publish_1_register(pv.current_sensor_, HAVELLS_PV_1_CURRENT + i * 2, TWO_DEC_UNIT); + publish_1_register(pv.active_power_sensor_, HAVELLS_PV_1_POWER + i, MULTIPLY_TEN_UNIT); + publish_1_register(pv.voltage_sampled_by_secondary_cpu_sensor_, HAVELLS_PV1_VOLTAGE_SAMPLED_BY_SECONDARY_CPU + i, + ONE_DEC_UNIT); + publish_1_register(pv.insulation_of_p_to_ground_sensor_, HAVELLS_PV1_INSULATION_OF_P_TO_GROUND + i, NO_DEC_UNIT); } - float frequency = havells_solar_get_1_register(HAVELLS_GRID_FREQUENCY * 2, TWO_DEC_UNIT); - float active_power = havells_solar_get_1_register(HAVELLS_SYSTEM_ACTIVE_POWER * 2, MULTIPLY_TEN_UNIT); - float reactive_power = havells_solar_get_1_register(HAVELLS_SYSTEM_REACTIVE_POWER * 2, TWO_DEC_UNIT); - float today_production = havells_solar_get_1_register(HAVELLS_TODAY_PRODUCTION * 2, TWO_DEC_UNIT); - float total_energy_production = havells_solar_get_2_registers(HAVELLS_TOTAL_ENERGY_PRODUCTION * 2, NO_DEC_UNIT); - float total_generation_time = havells_solar_get_2_registers(HAVELLS_TOTAL_GENERATION_TIME * 2, NO_DEC_UNIT); - float today_generation_time = havells_solar_get_1_register(HAVELLS_TODAY_GENERATION_TIME * 2, NO_DEC_UNIT); - float inverter_module_temp = havells_solar_get_1_register(HAVELLS_INVERTER_MODULE_TEMP * 2, NO_DEC_UNIT); - float inverter_inner_temp = havells_solar_get_1_register(HAVELLS_INVERTER_INNER_TEMP * 2, NO_DEC_UNIT); - float inverter_bus_voltage = havells_solar_get_1_register(HAVELLS_INVERTER_BUS_VOLTAGE * 2, NO_DEC_UNIT); - float insulation_pv_n_to_ground = havells_solar_get_1_register(HAVELLS_INSULATION_OF_PV_N_TO_GROUND * 2, NO_DEC_UNIT); - float gfci_value = havells_solar_get_1_register(HAVELLS_GFCI_VALUE * 2, NO_DEC_UNIT); - float dci_of_r = havells_solar_get_1_register(HAVELLS_DCI_OF_R * 2, NO_DEC_UNIT); - float dci_of_s = havells_solar_get_1_register(HAVELLS_DCI_OF_S * 2, NO_DEC_UNIT); - float dci_of_t = havells_solar_get_1_register(HAVELLS_DCI_OF_T * 2, NO_DEC_UNIT); - - if (this->frequency_sensor_ != nullptr) - this->frequency_sensor_->publish_state(frequency); - if (this->active_power_sensor_ != nullptr) - this->active_power_sensor_->publish_state(active_power); - if (this->reactive_power_sensor_ != nullptr) - this->reactive_power_sensor_->publish_state(reactive_power); - if (this->today_production_sensor_ != nullptr) - this->today_production_sensor_->publish_state(today_production); - if (this->total_energy_production_sensor_ != nullptr) - this->total_energy_production_sensor_->publish_state(total_energy_production); - if (this->total_generation_time_sensor_ != nullptr) - this->total_generation_time_sensor_->publish_state(total_generation_time); - if (this->today_generation_time_sensor_ != nullptr) - this->today_generation_time_sensor_->publish_state(today_generation_time); - if (this->inverter_module_temp_sensor_ != nullptr) - this->inverter_module_temp_sensor_->publish_state(inverter_module_temp); - if (this->inverter_inner_temp_sensor_ != nullptr) - this->inverter_inner_temp_sensor_->publish_state(inverter_inner_temp); - if (this->inverter_bus_voltage_sensor_ != nullptr) - this->inverter_bus_voltage_sensor_->publish_state(inverter_bus_voltage); - if (this->insulation_pv_n_to_ground_sensor_ != nullptr) - this->insulation_pv_n_to_ground_sensor_->publish_state(insulation_pv_n_to_ground); - if (this->gfci_value_sensor_ != nullptr) - this->gfci_value_sensor_->publish_state(gfci_value); - if (this->dci_of_r_sensor_ != nullptr) - this->dci_of_r_sensor_->publish_state(dci_of_r); - if (this->dci_of_s_sensor_ != nullptr) - this->dci_of_s_sensor_->publish_state(dci_of_s); - if (this->dci_of_t_sensor_ != nullptr) - this->dci_of_t_sensor_->publish_state(dci_of_t); + publish_1_register(this->frequency_sensor_, HAVELLS_GRID_FREQUENCY, TWO_DEC_UNIT); + publish_1_register(this->active_power_sensor_, HAVELLS_SYSTEM_ACTIVE_POWER, MULTIPLY_TEN_UNIT); + publish_1_register(this->reactive_power_sensor_, HAVELLS_SYSTEM_REACTIVE_POWER, TWO_DEC_UNIT); + publish_1_register(this->today_production_sensor_, HAVELLS_TODAY_PRODUCTION, TWO_DEC_UNIT); + publish_2_registers(this->total_energy_production_sensor_, HAVELLS_TOTAL_ENERGY_PRODUCTION, NO_DEC_UNIT); + publish_2_registers(this->total_generation_time_sensor_, HAVELLS_TOTAL_GENERATION_TIME, NO_DEC_UNIT); + publish_1_register(this->today_generation_time_sensor_, HAVELLS_TODAY_GENERATION_TIME, NO_DEC_UNIT); + publish_1_register(this->inverter_module_temp_sensor_, HAVELLS_INVERTER_MODULE_TEMP, NO_DEC_UNIT); + publish_1_register(this->inverter_inner_temp_sensor_, HAVELLS_INVERTER_INNER_TEMP, NO_DEC_UNIT); + publish_1_register(this->inverter_bus_voltage_sensor_, HAVELLS_INVERTER_BUS_VOLTAGE, NO_DEC_UNIT); + publish_1_register(this->insulation_pv_n_to_ground_sensor_, HAVELLS_INSULATION_OF_PV_N_TO_GROUND, NO_DEC_UNIT); + publish_1_register(this->gfci_value_sensor_, HAVELLS_GFCI_VALUE, NO_DEC_UNIT); + publish_1_register(this->dci_of_r_sensor_, HAVELLS_DCI_OF_R, NO_DEC_UNIT); + publish_1_register(this->dci_of_s_sensor_, HAVELLS_DCI_OF_S, NO_DEC_UNIT); + publish_1_register(this->dci_of_t_sensor_, HAVELLS_DCI_OF_T, NO_DEC_UNIT); } -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 ed5d13b8b61..a77b8bf977c 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -77,7 +77,8 @@ class HavellsSolar final : public PollingComponent, public modbus::ModbusClientD void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index d18ae0d9af6..dcea1afd04f 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -217,14 +217,14 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("havells_solar", role="client")(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): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/hbridge/fan/__init__.py b/esphome/components/hbridge/fan/__init__.py index 8ea8677ba2c..2cf1693b472 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/light/__init__.py b/esphome/components/hbridge/light/__init__.py index f9451e25949..f7866cb9907 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/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_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL +from esphome.types import ConfigType from .. import hbridge_ns @@ -21,7 +22,7 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( ) -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) diff --git a/esphome/components/hbridge/switch/__init__.py b/esphome/components/hbridge/switch/__init__.py index e26bd6b1d84..294be6ed5fe 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.cpp b/esphome/components/hbridge/switch/hbridge_switch.cpp index 1012a264f24..c8e472d7aab 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.cpp +++ b/esphome/components/hbridge/switch/hbridge_switch.cpp @@ -29,8 +29,9 @@ void HBridgeSwitch::dump_config() { LOG_PIN(" On Pin: ", this->on_pin_); LOG_PIN(" Off Pin: ", this->off_pin_); ESP_LOGCONFIG(TAG, " Pulse length: %" PRId32 " ms", this->pulse_length_); - if (this->wait_time_) + if (this->wait_time_) { ESP_LOGCONFIG(TAG, " Wait time %" PRId32 " ms", this->wait_time_); + } } void HBridgeSwitch::write_state(bool state) { diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 900acca6918..6a19f977a65 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -96,7 +96,6 @@ void HC8Component::dump_config() { " Warmup time: %" PRIu32 " s", this->warmup_seconds_); LOG_SENSOR(" ", "CO2", this->co2_sensor_); - this->check_uart_settings(9600); } } // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py index 29b428e3109..8a19cce8d18 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"] @@ -44,10 +47,13 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( baud_rate=9600, require_rx=True, require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, ) -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 +79,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/sensor.py b/esphome/components/hdc1080/sensor.py index e47a88545b1..b2b6dc533a2 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/sensor.py b/esphome/components/hdc2010/sensor.py index 15e19f2cc8e..ad0311fb4fe 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/sensor.py b/esphome/components/hdc2080/sensor.py index 777fc51cbaf..b5388b4c2be 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.cpp b/esphome/components/hdc302x/hdc302x.cpp index b50d34169a4..53d4c7f016b 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -38,7 +38,7 @@ void HDC302XComponent::dump_config() { ESP_LOGCONFIG(TAG, "HDC302x:\n" " Heater: %s", - this->heater_active_ ? "active" : "inactive"); + this->heater_active_ ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("inactive")); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temp_sensor_); diff --git a/esphome/components/hdc302x/sensor.py b/esphome/components/hdc302x/sensor.py index a6265b9b980..6d91c3df7c2 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 a3a1b19f5ad..4cb635b047c 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.cpp b/esphome/components/he60r/he60r.cpp index 84edbb2866a..008505e2bbf 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -38,14 +38,14 @@ CoverTraits HE60rCover::get_traits() { void HE60rCover::dump_config() { LOG_COVER("", "HE60R Cover", this); - this->check_uart_settings(1200, 1, uart::UART_CONFIG_PARITY_EVEN, 8); ESP_LOGCONFIG(TAG, " Open Duration: %.1fs\n" " Close Duration: %.1fs", this->open_duration_ / 1e3f, this->close_duration_ / 1e3f); auto restore = this->restore_state_(); - if (restore.has_value()) + if (restore.has_value()) { ESP_LOGCONFIG(TAG, " Saved position %d%%", (int) (restore->position * 100.f)); + } } void HE60rCover::endstop_reached_(CoverOperation operation) { @@ -59,7 +59,7 @@ void HE60rCover::endstop_reached_(CoverOperation operation) { if (this->last_command_ == operation) { float dur = (float) (now - this->start_dir_time_) / 1e3f; ESP_LOGD(TAG, "'%s' - %s endstop reached. Took %.1fs.", this->name_.c_str(), - operation == COVER_OPERATION_OPENING ? "Open" : "Close", dur); + operation == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("Open") : LOG_STR_LITERAL("Close"), dur); } this->publish_state(); } @@ -77,8 +77,9 @@ void HE60rCover::process_rx_(uint8_t data) { ESP_LOGV(TAG, "Process RX data %X", data); if (!this->query_seen_) { this->query_seen_ = data == QUERY_BYTE; - if (!this->query_seen_) + if (!this->query_seen_) { ESP_LOGD(TAG, "RX Byte %02X", data); + } return; } switch (data) { @@ -213,9 +214,9 @@ void HE60rCover::start_direction_(CoverOperation dir) { if (this->current_operation == dir) return; ESP_LOGD(TAG, "'%s' - Direction '%s' requested.", this->name_.c_str(), - dir == COVER_OPERATION_OPENING ? "OPEN" - : dir == COVER_OPERATION_CLOSING ? "CLOSE" - : "STOP"); + dir == COVER_OPERATION_OPENING ? LOG_STR_LITERAL("OPEN") + : dir == COVER_OPERATION_CLOSING ? LOG_STR_LITERAL("CLOSE") + : LOG_STR_LITERAL("STOP")); if (dir == this->next_direction_) { // either moving and needs to stop, or stopped and will move correctly on one trigger diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index cd1b7d2bb06..2583839ca81 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,18 +121,12 @@ 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])) diff --git a/esphome/components/hitachi_ac344/climate.py b/esphome/components/hitachi_ac344/climate.py index ebdf4e8db4a..15da73b79c2 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_ac424/climate.py b/esphome/components/hitachi_ac424/climate.py index fde4e775459..d2a66223b18 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/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index 964d26dfbcc..a9242598027 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -336,7 +336,8 @@ void HlkFm22xComponent::dump_config() { } if (this->enrolling_binary_sensor_) { LOG_BINARY_SENSOR(" ", "Enrolling", this->enrolling_binary_sensor_); - ESP_LOGCONFIG(TAG, " Current Value: %s", this->enrolling_binary_sensor_->state ? "ON" : "OFF"); + ESP_LOGCONFIG(TAG, " Current Value: %s", + this->enrolling_binary_sensor_->state ? LOG_STR_LITERAL("ON") : LOG_STR_LITERAL("OFF")); } if (this->face_count_sensor_) { LOG_SENSOR(" ", "Face Count", this->face_count_sensor_); diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index c92c76a20ad..9ef81f075d3 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -97,7 +97,8 @@ void HLW8012Component::update() { if (this->change_mode_every_ != 0 && this->change_mode_at_++ == this->change_mode_every_) { this->current_mode_ = !this->current_mode_; - ESP_LOGV(TAG, "Changing mode to %s mode", this->current_mode_ ? "CURRENT" : "VOLTAGE"); + ESP_LOGV(TAG, "Changing mode to %s mode", + this->current_mode_ ? LOG_STR_LITERAL("CURRENT") : LOG_STR_LITERAL("VOLTAGE")); this->change_mode_at_ = 0; this->sel_pin_->digital_write(this->current_mode_); } diff --git a/esphome/components/hlw8012/sensor.py b/esphome/components/hlw8012/sensor.py index 1d793ac6b17..384477be3df 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/sensor.py b/esphome/components/hlw8032/sensor.py index 846c9a398b5..7b069d85d0f 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/sensor.py b/esphome/components/hm3301/sensor.py index 9546ae1c3c6..2fa82b2710a 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/hmc5883l/sensor.py b/esphome/components/hmc5883l/sensor.py index cf3c594f360..a2e1f8054ad 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 00000000000..958b495c2e9 --- /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 00000000000..3de6a161e65 --- /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 00000000000..edce6ce4c2b --- /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 00000000000..c111c17834f --- /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 00000000000..dc2efcec44c --- /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 00000000000..e9ebceee889 --- /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 00000000000..50deacff631 --- /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 00000000000..66a141758e8 --- /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 00000000000..1ba8328fd2c --- /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 00000000000..4aa2c79bb15 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,470 @@ +#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 00000000000..83be385c7b1 --- /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 00000000000..e895115db45 --- /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 00000000000..d3d784928d3 --- /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 00000000000..82b12cb791c --- /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 7b23775b47b..1b66842f1eb 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 a943368dd77..6ea17b68313 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/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index 8f760772c3b..ab1389e13a3 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/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index 64374768275..abee957fda3 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/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index c299a731f24..55854cd6591 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/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index b59f9d23df2..265250c6959 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/time/__init__.py b/esphome/components/homeassistant/time/__init__.py index 05ca86a26e4..146b8278ea8 100644 --- a/esphome/components/homeassistant/time/__init__.py +++ b/esphome/components/homeassistant/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, CONF_TIMEZONE +from esphome.types import ConfigType from .. import homeassistant_ns @@ -16,7 +17,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 time_.register_time(var, config) await cg.register_component(var, config) diff --git a/esphome/components/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 93ae2b60566..5250e1c1c79 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/sensor.py b/esphome/components/honeywellabp/sensor.py index 25d03d31a66..4b116f0f161 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/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 2708e5d423f..299acd4b527 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 50deb1acf67..bd074ab6b58 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -10,6 +10,8 @@ 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 @@ -21,7 +23,7 @@ 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.py b/esphome/components/host/gpio.py index fcfb0b6c54f..e39d35d0770 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 7e8849b3e19..7274d9de57e 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/preferences.h b/esphome/components/host/preferences.h index 5f723e06758..b591fa0aab9 100644 --- a/esphome/components/host/preferences.h +++ b/esphome/components/host/preferences.h @@ -27,6 +27,9 @@ class HostPreferences final : public PreferencesMixin { 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; diff --git a/esphome/components/host/time/__init__.py b/esphome/components/host/time/__init__.py index d9a2f1207c3..6eb0cf954d9 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/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index 270bb2709dd..b323dd04360 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -68,8 +68,6 @@ void HrxlMaxsonarWrComponent::check_buffer_() { void HrxlMaxsonarWrComponent::dump_config() { ESP_LOGCONFIG(TAG, "HRXL MaxSonar WR Sensor:"); LOG_SENSOR(" ", "Distance", this); - // As specified in the sensor's data sheet - this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); } } // namespace esphome::hrxl_maxsonar_wr diff --git a/esphome/components/hrxl_maxsonar_wr/sensor.py b/esphome/components/hrxl_maxsonar_wr/sensor.py index d335d76dfa6..b81a8b273d4 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"] @@ -22,8 +23,16 @@ CONFIG_SCHEMA = sensor.sensor_schema( state_class=STATE_CLASS_MEASUREMENT, ).extend(uart.UART_DEVICE_SCHEMA) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hrxl_maxsonar_wr", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) -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/sensor.py b/esphome/components/hte501/sensor.py index 17ae3a3d1bf..bf9fe4000ee 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 54d7f5c77b2..de35d52a409 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any from esphome import automation import esphome.codegen as cg @@ -16,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"] @@ -63,14 +68,14 @@ CONF_BODY = "body" CONF_JSON = "json" -def validate_url(value): +def validate_url(value: Any) -> str: value = cv.url(value) 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_rp2 and config[CONF_VERIFY_SSL]: @@ -91,7 +96,35 @@ 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: @@ -150,8 +183,10 @@ CONFIG_SCHEMA = cv.All( 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])) @@ -167,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])) @@ -190,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", @@ -298,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) diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 84333e71690..43ab2e5b53a 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -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; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 85c6e8b3c7b..cf231e20bdc 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_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index a4375402410..10313be89db 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -16,7 +16,7 @@ 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; void HttpRequestIDF::dump_config() { @@ -142,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(); } } @@ -196,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/ota/__init__.py b/esphome/components/http_request/ota/__init__.py index b7026e0f55b..784e4ee47a5 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 @@ -42,7 +44,7 @@ CONFIG_SCHEMA = cv.All( @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) @@ -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/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 8893b96c65b..7e7594c3c3a 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 a706331d9ac..9bb748f1758 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 d84d80109aa..4bdc30e4cf3 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/sensor.py b/esphome/components/htu21d/sensor.py index 8808dc70f5f..86dca777255 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/sensor.py b/esphome/components/htu31d/sensor.py index 638a8d77c53..8960759d9bf 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 52f8864c60d..818ee732a3d 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 a404fbbade1..3522acf049a 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -131,7 +131,7 @@ SCAN_WIRINGS = { } -def _validate_scan_wiring(value): +def _validate_scan_wiring(value: Any) -> str: """Validate scan_wiring against the allowed names.""" value = cv.string(value).upper().replace(" ", "_") @@ -315,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 @@ -381,8 +381,6 @@ def _final_validate(config: ConfigType) -> ConfigType: if errs: raise cv.MultipleInvalid(errs) - return config - FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) @@ -479,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/hx711/sensor.py b/esphome/components/hx711/sensor.py index a5d11e92413..2739589c663 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 f899ce71ced..193db9b20dd 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.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 695a823cb7a..05557111fce 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -11,7 +11,6 @@ static const char *const PROTOCOL_NAMES[] = {HYDREON_RGXX_PROTOCOL_LIST(, HYDREO static const char *const IGNORE_STRINGS[] = {HYDREON_RGXX_IGNORE_LIST(, HYDREON_RGXX_COMMA)}; void HydreonRGxxComponent::dump_config() { - this->check_uart_settings(9600, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8); ESP_LOGCONFIG(TAG, "hydreon_rgxx:"); if (this->is_failed()) { ESP_LOGE(TAG, "Connection with hydreon_rgxx failed!"); diff --git a/esphome/components/hydreon_rgxx/sensor.py b/esphome/components/hydreon_rgxx/sensor.py index fdb606182f5..8e269fef9a0 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( @@ -129,8 +130,16 @@ CONFIG_SCHEMA = cv.All( _validate, ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hydreon_rgxx", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) -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/sensor.py b/esphome/components/hyt271/sensor.py index bf37646d4f1..3f006a65fe0 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 7b163d065eb..b053125446e 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -1,6 +1,7 @@ import logging import re import sys +from typing import Any from esphome import pins import esphome.codegen as cg @@ -52,9 +53,10 @@ from esphome.const import ( 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"] @@ -96,13 +98,13 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" MULTI_CONF = True -def validate_device(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): +def _bus_declare_type(value: Any) -> ID: if CORE.is_esp32: return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: @@ -114,7 +116,7 @@ def _bus_declare_type(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"): @@ -125,7 +127,7 @@ 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) @@ -142,7 +144,7 @@ def validate_config(config): return config -def validate_host_config(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"): @@ -229,7 +231,7 @@ CONFIG_SCHEMA = cv.All( ) -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") @@ -281,9 +283,14 @@ 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.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) @@ -353,7 +360,7 @@ async def to_code(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 @@ -370,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. @@ -385,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( diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index cc036b12c34..39a6aec7742 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -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; diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 4aca4f0fae1..7ca9537e2de 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_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 17279fda501..303944636bf 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c.host"; +static const char *const TAG = "i2c"; HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 1eb9944dcb9..ffdd2ba8bb2 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_device/__init__.py b/esphome/components/i2c_device/__init__.py index 531c363bd1e..f890fefdb9d 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/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 8e432695a14..c5e82beb46d 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_), @@ -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/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 1392d1d4ec1..c2173172372 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 66ca32b8303..c577ed092ed 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 65ad7df1af4..37895ac4e79 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -29,6 +29,10 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon 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 final : public I2SAudioIn, public microphone::Microphon /// @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 final : public I2SAudioIn, public microphone::Microphon 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 6d3c39c68eb..4dc15681bfa 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,7 +89,7 @@ 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: 16/24/32-bit audio and stereo at configured sample rate audio.set_stream_limits( @@ -133,14 +135,14 @@ def _set_stream_limits(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: @@ -207,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." @@ -238,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) @@ -260,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_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 5e271e671e5..9feaf39ffff 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -53,6 +53,13 @@ void I2SAudioSpeakerBase::dump_config() { void I2SAudioSpeakerBase::loop() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); + // A stop that arrives while stopped cancels any start that has not been processed yet + constexpr uint32_t stop_bits = SpeakerEventGroupBits::COMMAND_STOP | SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY; + if ((event_group_bits & stop_bits) && (this->state_ == speaker::STATE_STOPPED)) { + xEventGroupClearBits(this->event_group_, stop_bits | SpeakerEventGroupBits::COMMAND_START); + event_group_bits &= ~(stop_bits | SpeakerEventGroupBits::COMMAND_START); + } + if ((event_group_bits & SpeakerEventGroupBits::COMMAND_START) && (this->state_ == speaker::STATE_STOPPED)) { this->state_ = speaker::STATE_STARTING; xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); @@ -91,7 +98,14 @@ void I2SAudioSpeakerBase::loop() { this->speaker_task_handle_ = nullptr; this->stop_i2s_driver_(); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at + // the top of loop(): the audio source's task can raise a start at any point above, including + // during stop_i2s_driver_(), and nothing would ever re-issue it. + const EventBits_t bits_before_clear = xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ALL_BITS); + if (bits_before_clear & SpeakerEventGroupBits::COMMAND_START) { + ESP_LOGD(TAG, "Start requested while stopping; keeping the request"); + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); + } this->status_clear_error(); this->on_task_stopped(); @@ -111,21 +125,24 @@ void I2SAudioSpeakerBase::loop() { break; } + // Still starting up or winding down from a previous run + if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + break; + } + if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); this->status_momentary_error("driver-failure", 1000); break; } - if (this->speaker_task_handle_ == nullptr) { - xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, - &this->speaker_task_handle_); + xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, + &this->speaker_task_handle_); - if (this->speaker_task_handle_ == nullptr) { - ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt - } + if (this->speaker_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); + this->status_momentary_error("task-failure", 1000); + this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough @@ -211,8 +228,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t } bool I2SAudioSpeakerBase::has_buffered_data() const { - if (this->audio_ring_buffer_.use_count() > 0) { - std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + std::shared_ptr temp_ring_buffer = this->audio_ring_buffer_.lock(); + if (temp_ring_buffer != nullptr) { return temp_ring_buffer->available() > 0; } return false; @@ -229,8 +246,6 @@ void I2SAudioSpeakerBase::start() { if ((this->state_ == speaker::STATE_STARTING) || (this->state_ == speaker::STATE_RUNNING)) return; - // Mark STARTING immediately to avoid transient STOPPED observations before loop() processes COMMAND_START. - this->state_ = speaker::STATE_STARTING; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_START); } @@ -239,11 +254,10 @@ void I2SAudioSpeakerBase::stop() { this->stop_(false); } void I2SAudioSpeakerBase::finish() { this->stop_(true); } void I2SAudioSpeakerBase::stop_(bool wait_on_empty) { - if (this->is_failed()) - return; - if (this->state_ == speaker::STATE_STOPPED) + if (!this->is_ready() || this->is_failed()) return; + // Always set the bit, even when stopped, so loop() can cancel a start that is still pending if (wait_on_empty) { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP_GRACEFULLY); } else { diff --git a/esphome/components/iaqcore/sensor.py b/esphome/components/iaqcore/sensor.py index d3306fd0f86..1b905e4c63d 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 b1d332c1e59..64f87c167c1 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/ili9xxx/ili9xxx_display.cpp b/esphome/components/ili9xxx/ili9xxx_display.cpp index e8840c0cf19..0ed18c45da0 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.cpp +++ b/esphome/components/ili9xxx/ili9xxx_display.cpp @@ -116,8 +116,8 @@ void ILI9XXXDisplay::dump_config() { " Mirror_x: %s\n" " Mirror_y: %s\n" " Invert colors: %s", - this->color_order_ == display::COLOR_ORDER_BGR ? "BGR" : "RGB", YESNO(this->swap_xy_), - YESNO(this->mirror_x_), YESNO(this->mirror_y_), YESNO(this->pre_invertcolors_)); + this->color_order_ == display::COLOR_ORDER_BGR ? LOG_STR_LITERAL("BGR") : LOG_STR_LITERAL("RGB"), + YESNO(this->swap_xy_), YESNO(this->mirror_x_), YESNO(this->mirror_y_), YESNO(this->pre_invertcolors_)); if (this->is_failed()) { ESP_LOGCONFIG(TAG, " => Failed to init Memory: YES!"); diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 37a9afb84db..eaee31a1c7b 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -10,7 +10,14 @@ from PIL import Image, UnidentifiedImageError 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_FILE, CONF_ID, CONF_PLATFORM, CONF_TYPE +from esphome.const import ( + CONF_DEFAULTS, + CONF_FILE, + CONF_FILES, + CONF_ID, + CONF_PLATFORM, + CONF_TYPE, +) from esphome.core import CORE from esphome.types import ConfigType @@ -48,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()}") @@ -404,6 +414,120 @@ def get_image_metadata(image_id: str) -> ImageMetaData | None: 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. @@ -496,11 +620,17 @@ def _is_legacy_image_format(config: object) -> bool: proper error instead of the migration silently dropping the input. """ if isinstance(config, list): - # A bare list of (not-yet-platform-tagged) image dicts. + # 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 for entry in config + isinstance(entry, dict) + and CONF_PLATFORM not in entry + and CONF_FILES not in entry + for entry in config ) - if not isinstance(config, dict): + 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 ( @@ -532,18 +662,8 @@ def _flatten_legacy_image_config(config: object) -> list[dict]: def _add(entry: dict, extra: dict) -> None: merged = {**defaults, **extra, **entry} - # The legacy `defaults:`/type-grouped forms only applied `byte_order` to - # types that support it. Replicate that so an endian default merged into - # e.g. a binary image stays valid. - type_class = IMAGE_TYPE.get(str(merged.get(CONF_TYPE, "")).upper()) - if ( - CONF_BYTE_ORDER in merged - and isinstance(type_class, type) - and issubclass(type_class, ImageEncoder) - and not type_class.is_endian() - ): - del merged[CONF_BYTE_ORDER] - result.append(merged) + # 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 diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 5929f2b60a2..412d143a486 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.6") + 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 fa1b855d6c4..1babeb5b5a2 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 9dded85a468..352bb75d5fc 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 4266f5b78be..a34e2ab7931 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -1,28 +1,57 @@ 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"] -DEPENDENCIES = ["logger", "wifi"] +DEPENDENCIES = ["logger", "network"] 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 4ee703f363c..ffa7b79d9bf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -1,5 +1,5 @@ #include "improv_serial_component.h" -#ifdef USE_WIFI +#ifdef USE_IMPROV_SERIAL #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -7,6 +7,12 @@ #include "esphome/core/version.h" #include "esphome/components/logger/logger.h" +#include "esphome/components/network/util.h" +#ifdef USE_WIFI +#include "esphome/components/wifi/scan_list.h" +#endif + +#include namespace esphome::improv_serial { @@ -14,102 +20,70 @@ 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()) { + // The Improv state machine tracks Wi-Fi provisioning only. General device + // connectivity (e.g. Ethernet) is reported separately via GET_NETWORK_STATE. +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->has_sta()) { this->state_ = improv::STATE_PROVISIONED; - } else if (!wifi::global_wifi_component->is_disabled()) { + } else if (wifi::global_wifi_component != nullptr && !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(); } +#endif } 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) { - if (wifi::global_wifi_component->is_connected()) { +#ifdef USE_WIFI + if (this->state_ == improv::STATE_PROVISIONING && wifi::global_wifi_component != nullptr && + wifi::global_wifi_component->is_connected()) { + // Being connected is not enough: re-provisioning a device that is already online leaves the + // prior network up until it drops, so check that the joined network is the requested one + // before reporting success. Same test as the wifi.connect action. + char ssid_buf[wifi::SSID_BUFFER_SIZE]; + if (strcmp(wifi::global_wifi_component->wifi_ssid_to(ssid_buf), this->connecting_sta_.get_ssid().c_str()) == 0) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; 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); } } +#endif } 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_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 // 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,8 +106,14 @@ 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_ESP32) @@ -177,43 +157,112 @@ 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; -#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); - } - } -#endif #ifdef USE_WEBSERVER - for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) { - if (ip.is_ip4()) { +void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &builder, [[maybe_unused]] bool wifi_first) { + // The webserver listens on every interface, so advertise each one that has a usable IPv4. + // network::get_ip_addresses() can't be used here: it returns only the highest-priority + // interface's addresses, which are all-unset (0.0.0.0) when e.g. Ethernet has no link while + // the device is online via Wi-Fi, and 0.0.0.0 must not become the advertised URL. OpenThread + // is omitted: it only ever has IPv6 addresses, which cannot form an IPv4 http:// URL. + const auto append_urls = [&builder](const network::IPAddresses &addresses) { + for (const auto &ip : addresses) { + if (!ip.is_ip4() || !ip.is_set()) + continue; 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]; - snprintf(webserver_url, sizeof(webserver_url), "http://%s:%u", ip_buf, USE_WEBSERVER_PORT); - urls.emplace_back(webserver_url); - break; + // 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"); + } } - } + }; +#ifdef USE_WIFI + // Clients redirect to the first URL, so the interface the client just configured has to lead: + // another interface's address can be on a subnet that client cannot reach. + const auto append_wifi_urls = [&append_urls]() { + if (wifi::global_wifi_component != nullptr) + append_urls(wifi::global_wifi_component->get_ip_addresses()); + }; + if (wifi_first) + append_wifi_urls(); #endif - std::vector data = improv::build_rpc_response(command, urls, false); - return data; +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) + append_urls(ethernet::global_eth_component->get_ip_addresses()); +#endif +#ifdef USE_MODEM + if (modem::global_modem_component != nullptr) + append_urls(modem::global_modem_component->get_ip_addresses()); +#endif +#ifdef USE_WIFI + if (!wifi_first) + append_wifi_urls(); +#endif +} +#endif // USE_WEBSERVER + +void ImprovSerialComponent::send_settings_response_(improv::Command command) { + std::array buf; + improv::RpcResponseBuilder builder(buf, command); +#ifdef USE_IMPROV_SERIAL_NEXT_URL + this->add_next_url_(builder, MAX_NEXT_URL_LEN); +#endif +#ifdef USE_WEBSERVER + // This response only ever answers Wi-Fi provisioning, so lead with the Wi-Fi URL as it did + // before other interfaces were reported. + this->add_webserver_urls_(builder, /*wifi_first=*/true); +#endif + 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(); @@ -232,7 +281,8 @@ 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()) { +#ifdef USE_WIFI + if (wifi::global_wifi_component == nullptr || 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"); @@ -244,66 +294,113 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command sta.set_password(command.password.c_str()); this->connecting_sta_ = sta; + // Sampled before start_connecting(): the old connection drops asynchronously after it. + const bool switching = wifi::global_wifi_component->is_connected(); wifi::global_wifi_component->set_sta(sta); wifi::global_wifi_component->start_connecting(sta); this->set_state_(improv::STATE_PROVISIONING); ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); }); + this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, + [this]() { this->on_wifi_connect_timeout_(); }); +#else + // No Wi-Fi support compiled in; there is nothing to provision. + ESP_LOGW(TAG, "Wi-Fi not supported; cannot provision"); + this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); +#endif 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. + case improv::GET_CURRENT_STATE: { + // This state machine tracks Wi-Fi provisioning only. When Wi-Fi is disabled or not + // compiled in, provisioning is unavailable -> report STOPPED so the client doesn't + // offer a Wi-Fi form. General connectivity (e.g. Ethernet) is reported separately + // via GET_NETWORK_STATE. +#ifdef USE_WIFI + if (wifi::global_wifi_component == nullptr || wifi::global_wifi_component->is_disabled()) { + // 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); } +#else + this->send_current_state_(improv::STATE_STOPPED); +#endif 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; + // Declared out here because the terminating empty response is sent with or without Wi-Fi + std::array buf; +#ifdef USE_WIFI const auto &results = wifi::global_wifi_component->get_scan_result(); - for (auto &scan : results) { - if (scan.get_is_hidden()) + 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)); } +#endif // USE_WIFI // 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; + } + case improv::GET_NETWORK_STATE: { + // Reports general device connectivity and which network interfaces are present, decoupled + // from the Wi-Fi-only provisioning state machine. data[0] is a decimal flags byte; + // when online, the reachable device URL(s) follow. + uint8_t flags = 0; + if (network::is_connected()) + flags |= improv::NETWORK_IS_ONLINE; +#ifdef USE_WIFI + flags |= improv::NETWORK_SUPPORTS_WIFI; +#endif +#ifdef USE_ETHERNET + flags |= improv::NETWORK_SUPPORTS_ETHERNET; +#endif +#ifdef USE_OPENTHREAD + flags |= improv::NETWORK_SUPPORTS_THREAD; +#endif +#ifdef USE_MODEM + flags |= improv::NETWORK_SUPPORTS_MODEM; +#endif + std::array buf; + improv::RpcResponseBuilder builder(buf, improv::GET_NETWORK_STATE); + // Every flag bit fits int8_t's positive range, so int8_to_str renders the byte + static_assert(improv::NETWORK_SUPPORTS_MODEM <= 0x7F, "network flags no longer fit int8_to_str"); + char flags_buf[4]; // uint8_t: max "255" + null + char *flags_end = int8_to_str(flags_buf, static_cast(flags)); + builder.add_string(flags_buf, flags_end - flags_buf); +#ifdef USE_WEBSERVER + // Not tied to one interface, so follow the configured priority the way + // network::get_ip_addresses() does: a wifi-first network priority list leads with Wi-Fi. + if (flags & improv::NETWORK_IS_ONLINE) { +#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI) + this->add_webserver_urls_(builder, /*wifi_first=*/true); +#else + this->add_webserver_urls_(builder, /*wifi_first=*/false); +#endif + } +#endif + this->send_response_(builder.finish(false)); return true; } default: { @@ -331,17 +428,26 @@ 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()); } +#ifdef USE_WIFI void ImprovSerialComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network"); wifi::global_wifi_component->clear_sta(); } +#endif ImprovSerialComponent *global_improv_serial_component = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 4df6f6df2d2..68cdd752149 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -1,15 +1,23 @@ #pragma once #include "esphome/components/improv_base/improv_base.h" -#include "esphome/components/wifi/wifi_component.h" +#include "esphome/components/logger/logger.h" +#include "esphome/components/network/util.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" -#ifdef USE_WIFI +#ifdef USE_IMPROV_SERIAL #include +#include #include -#ifdef USE_ESP32 +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +#ifdef USE_IMPROV_SERIAL_UART +#include "esphome/components/uart/uart_component.h" +#elif defined(USE_ESP32) #include #ifdef USE_LOGGER_USB_SERIAL_JTAG #include @@ -44,6 +52,31 @@ enum ImprovSerialType : uint8_t { static const uint16_t IMPROV_SERIAL_TIMEOUT = 100; static const uint8_t IMPROV_SERIAL_VERSION = 1; +#ifdef USE_WIFI +// Wi-Fi connect failure timers: a fresh provision reports at 30 s (stock behavior), while +// switching networks on an already-connected device (disconnect + reconnect) can legitimately +// take longer; 90 s matches esp32_improv's default wifi_timeout. +static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 30000; +static const uint32_t WIFI_SWITCH_TIMEOUT_MS = 90000; +#endif + +// 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. Reserves the first URL only; a device with +// several interfaces online adds the rest best-effort and warns if one no longer fits. +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; @@ -52,6 +85,10 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv 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); @@ -59,13 +96,69 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv 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); +#ifdef USE_WIFI void on_wifi_connect_timeout_(); +#endif - std::vector build_rpc_settings_response_(improv::Command command); - std::vector build_version_info_(); +#ifdef USE_WEBSERVER + /// Append one web server URL per interface that has a usable IPv4. With wifi_first the Wi-Fi + /// URL leads, for responses to Wi-Fi provisioning; otherwise interfaces go in priority order. + void add_webserver_urls_(improv::RpcResponseBuilder &builder, [[maybe_unused]] bool wifi_first); +#endif + 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,15 +176,20 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv '\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 std::vector rx_buffer_; uint32_t last_read_byte_{0}; +#ifdef USE_WIFI wifi::WiFiAP connecting_sta_; +#endif improv::State state_{improv::STATE_AUTHORIZED}; }; diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 621fd62e82b..97482f81a01 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/sensor.py b/esphome/components/ina226/sensor.py index 2a7b3fc2128..4fd98fbcd40 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/sensor.py b/esphome/components/ina260/sensor.py index b98b4ce6cb9..b7b94a248b4 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 15e2faba07f..7bb589f0b15 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_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index d3acf00eef6..fec5cd2f131 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -209,7 +209,8 @@ void INA2XX::dump_config() { " CURRENT_LSB = %f\n" " SHUNT_CAL = %d", this->shunt_resistance_ohm_, this->max_current_a_, this->shunt_tempco_ppm_c_, - (uint8_t) this->adc_range_, this->adc_range_ ? "±40.96 mV" : "±163.84 mV", this->current_lsb_, + (uint8_t) this->adc_range_, + this->adc_range_ ? LOG_STR_LITERAL("±40.96 mV") : LOG_STR_LITERAL("±163.84 mV"), this->current_lsb_, this->shunt_cal_); ESP_LOGCONFIG(TAG, " ADC Samples = %d; ADC times: Bus = %d μs, Shunt = %d μs, Temp = %d μs", diff --git a/esphome/components/ina2xx_i2c/sensor.py b/esphome/components/ina2xx_i2c/sensor.py index 1a470aa6286..4bcbca8762b 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/sensor.py b/esphome/components/ina2xx_spi/sensor.py index 3ebe2cac733..dc72dce7a93 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/sensor.py b/esphome/components/ina3221/sensor.py index acf7d7cdf0f..db8dad2f542 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 f8e77209b24..d04c82ea965 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 9b97995a964..83039a5a9bf 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -59,11 +59,6 @@ void Infrared::setup() { // Set up traits based on configuration this->traits_.set_supports_transmitter(this->has_transmitter()); this->traits_.set_supports_receiver(this->has_receiver()); - - // Register as listener for received IR data - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void Infrared::dump_config() { @@ -75,8 +70,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 6d91c97cce3..afbde57be2f 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -119,7 +119,8 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } @@ -134,7 +135,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 4df22aa9de9..d360142bcba 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 4c90d6d35bf..726ea8c5ea1 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 final : 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 final : public Component, public esp32_ble_tracker::ESPB }; } // 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 b446c9f1e23..84a207020ea 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 47c8c898e54..350a0c16527 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/integration/sensor.py b/esphome/components/integration/sensor.py index 8d784df6720..82e8ba8df8e 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_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index b7332ee81fa..91f47d831fc 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 64fe3707b18..2c6fda2af49 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature.esp32"; +static const char *const TAG = "internal_temperature"; void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc33..c4ab33b0a56 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.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.rp2"; +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 = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index be72ab6f517..50c597f6f1b 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 805138071eb..40ac216f0c4 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,5 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component +from esphome.components.esp32.const import VARIANT_ESP32 from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -16,6 +18,7 @@ from esphome.const import ( 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_( @@ -43,10 +46,14 @@ 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) + if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32: + # temprature_sens_read() lives in the esp_phy blob, which is excluded by default + include_builtin_idf_component("esp_phy") + if CORE.using_zephyr and CORE.is_nrf52: zephyr_add_prj_conf("SENSOR", True) zephyr_add_prj_conf("TEMP_NRF5", True) diff --git a/esphome/components/interval/__init__.py b/esphome/components/interval/__init__.py index ac9219ff6a7..11c3e15b0d4 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/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 3218889721c..288bd916738 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,7 +3,12 @@ from typing import Any import esphome.codegen as cg -from esphome.components import infrared, remote_receiver, remote_transmitter +from esphome.components import ( + infrared, + remote_base, + remote_receiver, + remote_transmitter, +) from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -82,8 +87,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) # Set receiver demodulation frequency if specified (metadata only, no hardware effect) if CONF_RECEIVER_FREQUENCY in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index c13c6198cb6..ceb4c9a67c3 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -97,10 +97,6 @@ void RfProxy::setup() { // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); - - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void RfProxy::dump_config() { diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 5fc683354ba..1aa4394fe84 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -56,7 +56,8 @@ class RfProxy final : public radio_frequency::RadioFrequency { /// Set the remote transmitter component void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component + /// Set the remote receiver component; the listener registration happens from codegen, see + /// remote_base.attach_receiver void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index a243909837f..28b8fd5953a 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,7 +1,12 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import radio_frequency, remote_receiver, remote_transmitter +from esphome.components import ( + radio_frequency, + remote_base, + remote_receiver, + remote_transmitter, +) import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -66,5 +71,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_transmitter(transmitter)) if CONF_REMOTE_RECEIVER_ID in config: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 51c5fc61181..57bf86c4c6c 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -2,6 +2,9 @@ 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 @@ -33,8 +36,10 @@ from esphome.const import ( CONF_UPDATE_INTERVAL, CONF_WIDTH, ) -from esphome.cpp_generator import RawExpression +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"] @@ -97,16 +102,16 @@ class IT8951Model: models: dict[str, "IT8951Model"] = {} - def __init__(self, name: str, **defaults): + 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, fallback=None): + def get_default(self, key: str, fallback: Any = None) -> Any: return self.defaults.get(key, fallback) - def get_dimensions(self, config) -> tuple[int, int]: + 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] @@ -181,14 +186,16 @@ DIMENSION_SCHEMA = cv.Schema( ) -def _model_pin_option(model, key, schema): +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): +def _model_schema(config: ConfigType) -> cv.Schema: model = IT8951Model.models[config[CONF_MODEL]] has_default_dimensions = ( model.get_default(CONF_WIDTH) is not None @@ -293,7 +300,7 @@ def _model_schema(config): return schema.extend(pin_extra) -def _customise_schema(config): +def _customise_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of( @@ -336,7 +343,7 @@ def _customise_schema(config): CONFIG_SCHEMA = _customise_schema -def _final_validate(config): +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 @@ -351,13 +358,12 @@ def _final_validate(config): config[CONF_UPDATE_INTERVAL] = update_interval("never") else: config[CONF_SHOW_TEST_CARD] = True - return config FINAL_VALIDATE_SCHEMA = _final_validate -async def to_code(config): +async def to_code(config: ConfigType) -> None: model = IT8951Model.models[config[CONF_MODEL]] width, height = model.get_dimensions(config) @@ -424,7 +430,12 @@ async def to_code(config): ), synchronous=True, ) -async def it8951_update_action_to_code(config, action_id, template_arg, args): +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): diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index cc2bddeda7b..179c2e5f63d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -740,7 +740,7 @@ bool IT8951Display::prepare_update_region_(UpdateMode &mode) { 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"); + this->grayscale_ ? LOG_STR_LITERAL("grayscale") : LOG_STR_LITERAL("mono")); return true; } @@ -1063,25 +1063,27 @@ void IT8951Display::dump_config() { 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_); + 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_ : LOG_STR_LITERAL("(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_ ? LOG_STR_LITERAL("DPY_AREA (0x0034, legacy)") + : LOG_STR_LITERAL("DPY_BUF_AREA (0x0037)"), + YESNO(this->sleep_when_done_), this->full_update_every_, YESNO(this->invert_colors_), + this->grayscale_ ? LOG_STR_LITERAL("4bpp grayscale") : LOG_STR_LITERAL("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_); diff --git a/esphome/components/jsn_sr04t/sensor.py b/esphome/components/jsn_sr04t/sensor.py index 214724aa3f8..0c4187b823f 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 3cb89a6cd9f..af7eb7e733d 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,7 +12,7 @@ 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 diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 70f6d4eaa76..24e5d259218 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -26,8 +26,6 @@ void KamstrupKMPComponent::dump_config() { LOG_SENSOR(" ", "Custom Sensor", this->custom_sensors_[i]); ESP_LOGCONFIG(TAG, " Command: 0x%04X", this->custom_commands_[i]); } - - this->check_uart_settings(1200, 2, uart::UART_CONFIG_PARITY_NONE, 8); } void KamstrupKMPComponent::update() { diff --git a/esphome/components/kamstrup_kmp/sensor.py b/esphome/components/kamstrup_kmp/sensor.py index 75ec432ad9f..f6c236b72df 100644 --- a/esphome/components/kamstrup_kmp/sensor.py +++ b/esphome/components/kamstrup_kmp/sensor.py @@ -23,6 +23,7 @@ from esphome.const import ( UNIT_KILOWATT, UNIT_LITRE_PER_HOUR, ) +from esphome.types import ConfigType CODEOWNERS = ["@cfeenstra1024"] DEPENDENCIES = ["uart"] @@ -101,11 +102,17 @@ CONFIG_SCHEMA = ( ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( - "kamstrup_kmp", baud_rate=1200, require_rx=True, require_tx=True + "kamstrup_kmp", + baud_rate=1200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=2, ) -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 1f4519df2d0..bf47b6df888 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 cb7d47b7f0d..42f02d39d4f 100644 --- a/esphome/components/key_collector/key_collector.cpp +++ b/esphome/components/key_collector/key_collector.cpp @@ -14,27 +14,36 @@ 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) + if (this->min_length_ > 0) { ESP_LOGCONFIG(TAG, " min length: %d", this->min_length_); - if (this->max_length_ > 0) + } + if (this->max_length_ > 0) { ESP_LOGCONFIG(TAG, " max length: %d", this->max_length_); - if (!this->back_keys_.empty()) + } + if (!this->back_keys_.empty()) { ESP_LOGCONFIG(TAG, " erase keys '%s'", this->back_keys_.c_str()); - if (!this->clear_keys_.empty()) + } + if (!this->clear_keys_.empty()) { ESP_LOGCONFIG(TAG, " clear keys '%s'", this->clear_keys_.c_str()); - if (!this->start_keys_.empty()) + } + if (!this->start_keys_.empty()) { ESP_LOGCONFIG(TAG, " start keys '%s'", this->start_keys_.c_str()); + } if (!this->end_keys_.empty()) { ESP_LOGCONFIG(TAG, " end keys '%s'\n" " end key is required: %s", this->end_keys_.c_str(), ONOFF(this->end_key_required_)); } - if (!this->allowed_keys_.empty()) + if (!this->allowed_keys_.empty()) { ESP_LOGCONFIG(TAG, " allowed keys '%s'", this->allowed_keys_.c_str()); - if (this->timeout_ > 0) + } + 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/text_sensor/__init__.py b/esphome/components/key_collector/text_sensor/__init__.py index 1676cf7bdf8..e32d15df2e6 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/sensor.py b/esphome/components/kmeteriso/sensor.py index 4f6cb7d091a..3e007d13105 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 1475ca61ae7..cb04afe4377 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -1,88 +1,74 @@ #include "kuntze.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" 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}; +static constexpr uint16_t REGISTER_PH = 4136; +static constexpr uint16_t REGISTER_TEMPERATURE = 4160; +static constexpr uint16_t REGISTER_DIS1 = 4680; +static constexpr uint16_t REGISTER_DIS2 = 6000; +static constexpr uint16_t REGISTER_REDOX = 4688; +static constexpr uint16_t REGISTER_EC = 4728; +static constexpr uint16_t REGISTER_OCI = 5832; +static constexpr uint16_t REGISTER[] = {REGISTER_PH, REGISTER_TEMPERATURE, REGISTER_DIS1, REGISTER_DIS2, + REGISTER_REDOX, REGISTER_EC, REGISTER_OCI}; -// 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_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status) || registers.size() < 2) + return; -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]); }; + // Each value is a register pair: the reading, then the number of decimal places in its low byte. + float value = registers[0]; + for (uint16_t i = 0; i < (registers[1] & 0xFF); i++) + value /= 10.0f; - this->waiting_ = false; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(KUNTZE_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Data: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); - - float value = (float) get_16bit(0); - for (int i = 0; i < data[3]; i++) - value /= 10.0; - switch (this->state_) { - case 1: + switch (start_address) { + case REGISTER_PH: ESP_LOGD(TAG, "pH=%.1f", value); if (this->ph_sensor_ != nullptr) this->ph_sensor_->publish_state(value); break; - case 2: + case REGISTER_TEMPERATURE: ESP_LOGD(TAG, "temperature=%.1f", value); if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(value); break; - case 3: + case REGISTER_DIS1: ESP_LOGD(TAG, "DIS1=%.1f", value); if (this->dis1_sensor_ != nullptr) this->dis1_sensor_->publish_state(value); break; - case 4: + case REGISTER_DIS2: ESP_LOGD(TAG, "DIS2=%.1f", value); if (this->dis2_sensor_ != nullptr) this->dis2_sensor_->publish_state(value); break; - case 5: + case REGISTER_REDOX: ESP_LOGD(TAG, "REDOX=%.1f", value); if (this->redox_sensor_ != nullptr) this->redox_sensor_->publish_state(value); break; - case 6: + case REGISTER_EC: ESP_LOGD(TAG, "EC=%.1f", value); if (this->ec_sensor_ != nullptr) this->ec_sensor_->publish_state(value); break; - case 7: + case REGISTER_OCI: ESP_LOGD(TAG, "OCI=%.1f", value); if (this->oci_sensor_ != nullptr) this->oci_sensor_->publish_state(value); break; } - if (++this->state_ > 7) - this->state_ = 0; } -void Kuntze::loop() { - uint32_t now = App.get_loop_component_start_time(); - // timeout after 15 seconds - if (this->waiting_ && (now - this->last_send_ > 15000)) { - ESP_LOGW(TAG, "timed out waiting for response"); - this->waiting_ = false; - } - if (this->waiting_ || (this->state_ == 0)) - return; - this->last_send_ = now; - send(CMD_READ_REG, REGISTER[this->state_ - 1], 2); - this->waiting_ = true; +void Kuntze::update() { + for (uint16_t reg : REGISTER) + this->read_holding_registers(reg, 2); } -void Kuntze::update() { this->state_ = 1; } - void Kuntze::dump_config() { ESP_LOGCONFIG(TAG, "Kuntze:\n" diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 28c80897483..84197b379d3 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -18,18 +18,14 @@ class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice void set_ec_sensor(sensor::Sensor *ec_sensor) { ec_sensor_ = ec_sensor; } void set_oci_sensor(sensor::Sensor *oci_sensor) { oci_sensor_ = oci_sensor; } - void loop() override; void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; protected: - int state_{0}; - bool waiting_{false}; - uint32_t last_send_{0}; - sensor::Sensor *ph_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *dis1_sensor_{nullptr}; diff --git a/esphome/components/kuntze/sensor.py b/esphome/components/kuntze/sensor.py index c11ede9db69..51d23991e28 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -89,14 +89,14 @@ CONFIG_SCHEMA = ( ) -def _final_validate(config: ConfigType) -> ConfigType: - return modbus.final_validate_modbus_device("kuntze", role="client")(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): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index cbd733b6113..a5dda6ca437 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -150,7 +150,8 @@ void Lc709203f::dump_config() { " Pack Size: %d mAH\n" " Pack APA: 0x%02X\n" " Pack Rated Voltage: 3.%sV", - this->pack_size_, this->apa_, this->pack_voltage_ == 0x0000 ? "8" : "7"); + this->pack_size_, this->apa_, + this->pack_voltage_ == 0x0000 ? LOG_STR_LITERAL("8") : LOG_STR_LITERAL("7")); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index d4e62134257..3319c9be4b9 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 bf1072ce667..08ec3957200 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 0a77daf336b..10e21c87d19 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_menu/__init__.py b/esphome/components/lcd_menu/__init__.py index 3f3162e31e7..88b8ac21d42 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_pcf8574/display.py b/esphome/components/lcd_pcf8574/display.py index 410c7f81b7e..85a79e99cec 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/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 360e56330af..19786f38d32 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/binary_sensor.py b/esphome/components/ld2410/binary_sensor.py index fb5b5cabff9..2b687335320 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 fa6f31ee258..59a95583318 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/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f5..914de8e1455 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 a0cce36d16d..061846f1f19 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -121,7 +121,7 @@ class LD2410Component final : 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 01dbcc785db..3500d704a1e 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/select/__init__.py b/esphome/components/ld2410/select/__init__.py index 9c4f654aa17..e89e3d59976 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/sensor.py b/esphome/components/ld2410/sensor.py index 459018e263e..ca42b3a1d30 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 4276b28a718..6d8053ddd60 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/text_sensor.py b/esphome/components/ld2410/text_sensor.py index a34c8ec0d2d..25c61a4825f 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 e701d0bda90..82db3198611 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 98fa5965cda..80cff014c0d 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 e0ca285265a..5a1ea2e6a5d 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/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dcb..7041b7539fb 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 f722f938ae4..a52402c2ea3 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -124,7 +124,7 @@ class LD2412Component final : 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 b6e1c8d039e..1a81c330adf 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/select/__init__.py b/esphome/components/ld2412/select/__init__.py index a54cd700ed5..02ecf2c30f5 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/sensor.py b/esphome/components/ld2412/sensor.py index f562afe0ee8..0b6e676931d 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 7a87e9e4836..e7f71222fdb 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/text_sensor.py b/esphome/components/ld2412/text_sensor.py index 22fba5193e3..c8e9f42ef39 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 71a5fa13e48..5a5aabeba03 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 5ebc4a9f635..76b42c03628 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/button/__init__.py b/esphome/components/ld2420/button/__init__.py index dfeb121c91f..cfcffd0922d 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/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index ae622cda283..e342ead4148 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" @@ -703,7 +701,8 @@ uint8_t LD2420Component::set_config_mode(bool enable) { cmd_frame.data_length += sizeof(CMD_PROTOCOL_VER); } cmd_frame.footer = CMD_FRAME_FOOTER; - ESP_LOGV(TAG, "Sending set config %s command: %2X", enable ? "enable" : "disable", cmd_frame.command); + ESP_LOGV(TAG, "Sending set config %s command: %2X", enable ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"), + cmd_frame.command); return this->send_cmd_from_array(cmd_frame); } @@ -746,7 +745,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 ae44b160650..e13d0271e10 100644 --- a/esphome/components/ld2420/ld2420.h +++ b/esphome/components/ld2420/ld2420.h @@ -105,10 +105,9 @@ class LD2420Component final : 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 a2637b7b064..448639c9112 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/select/__init__.py b/esphome/components/ld2420/select/__init__.py index b9059c120fc..cd66064e475 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/sensor/__init__.py b/esphome/components/ld2420/sensor/__init__.py index 97acdabd7ba..f98d63585b6 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/text_sensor/__init__.py b/esphome/components/ld2420/text_sensor/__init__.py index 14d982e5fb8..cee8f25c1fe 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/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 585c9f7bf54..4c37f4fcd1e 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 89e629253aa..779d151fd91 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 682487d750a..42cadd2052d 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 0dc2638aad9..4b41d63a88a 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 10f9bb874a4..c4f06ad2248 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 799c0703f25..4f242076d66 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 4f237dc94f1..d91b42426ad 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 ce58cedf117..40462e202d1 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 0c0c92377be..084f79ee1bc 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 4e5d7d419ba..a8b978ef488 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 cba1b68a15f..deac04e86f9 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 00000000000..af1e501a6ab --- /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 00000000000..74095d5dede --- /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 00000000000..a664890a86a --- /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 00000000000..fb398a9a59d --- /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 00000000000..c2221434531 --- /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 00000000000..b7c3f54a6f9 --- /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 00000000000..73fc7df3311 --- /dev/null +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -0,0 +1,1880 @@ +#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_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("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 00000000000..bea38043128 --- /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 00000000000..236b049f53b --- /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 00000000000..b0b1b6f72b1 --- /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 00000000000..3101b4d3cd0 --- /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 00000000000..7f5e528b846 --- /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 00000000000..a6b524a6650 --- /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 00000000000..f380089a1e3 --- /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 00000000000..cceefb3837a --- /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 00000000000..a414308b654 --- /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 00000000000..7542f7b1ffa --- /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 00000000000..44773f802fd --- /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 00000000000..0e8e2e80e7e --- /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/output.py b/esphome/components/ledc/output.py index 95df1fba237..e5e7c3dcbe4 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 97c0fd455b9..50dc7877990 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -26,6 +26,7 @@ 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: F401 @@ -181,6 +182,9 @@ def get_download_types(storage_json: StorageJSON = None): 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)", @@ -296,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( { @@ -310,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) @@ -460,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]) @@ -506,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. @@ -610,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 791a2659a94..49978786577 100644 --- a/esphome/components/libretiny/generate_components.py +++ b/esphome/components/libretiny/generate_components.py @@ -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})" diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 313b36d31ed..d0bd3bf26b3 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 8365d590c22..fd86c48b20d 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/output.py b/esphome/components/libretiny_pwm/output.py index 6f71530aaf7..716ccfad2b2 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 7c4d7ed4317..ab9624c3649 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, @@ -61,6 +67,7 @@ from .effects import ( from .types import ( # noqa: F401 AddressableLight, AddressableLightState, + ChannelColors, ColorMode, LightOutput, LightState, @@ -71,6 +78,8 @@ from .types import ( # noqa: F401 light_ns, ) +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] IS_PLATFORM_COMPONENT = True @@ -165,7 +174,105 @@ 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, @@ -173,7 +280,7 @@ def _final_validate(config: ConfigType) -> ConfigType: """ data = _get_data() if not data.effect_refs and not data.effect_cycle_refs: - return config + return # Drain the lists so we only validate once even though # FINAL_VALIDATE_SCHEMA runs for each light platform instance. @@ -217,8 +324,6 @@ def _final_validate(config: ConfigType) -> ConfigType: path=[cv.ROOT_CONFIG_PATH] + ref.component_path, ) - return config - FINAL_VALIDATE_SCHEMA = _final_validate @@ -235,6 +340,10 @@ RESTORE_MODES = { "RESTORE_AND_ON": LightRestoreMode.LIGHT_RESTORE_AND_ON, } +# Schema default that also matches the C++ initializer in light_state.h; codegen +# skips the setter when the config equals it. +DEFAULT_FLASH_TRANSITION_LENGTH = "0s" + LIGHT_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -282,7 +391,7 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( CONF_DEFAULT_TRANSITION_LENGTH, default="1s" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_FLASH_TRANSITION_LENGTH, default="0s" + CONF_FLASH_TRANSITION_LENGTH, default=DEFAULT_FLASH_TRANSITION_LENGTH ): cv.positive_time_period_milliseconds, cv.Optional(CONF_EFFECTS): validate_effects(MONOCHROMATIC_EFFECTS), } @@ -397,9 +506,12 @@ async def setup_light_core_(light_var, config, output_var): default_transition_length := config.get(CONF_DEFAULT_TRANSITION_LENGTH) ) is not None: cg.add(light_var.set_default_transition_length(default_transition_length)) + # Skip the setter when the config matches the C++ initializer. if ( flash_transition_length := config.get(CONF_FLASH_TRANSITION_LENGTH) - ) is not None: + ) is not None and flash_transition_length != cv.time_period( + DEFAULT_FLASH_TRANSITION_LENGTH + ): cg.add(light_var.set_flash_transition_length(flash_transition_length)) if (gamma_correct := config.get(CONF_GAMMA_CORRECT)) is not None: cg.add(light_var.set_gamma_correct(gamma_correct)) @@ -409,7 +521,8 @@ async def setup_light_core_(light_var, config, output_var): effects = await cg.build_registry_list( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) - cg.add(light_var.add_effects(effects)) + if effects: + cg.add(light_var.add_effects(effects)) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) @@ -453,3 +566,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/channel_colors.h b/esphome/components/light/channel_colors.h new file mode 100644 index 00000000000..9d8f46d5753 --- /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/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index e793226bb1e..12eb6a30089 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -5,7 +5,11 @@ namespace esphome::light { uint8_t ESPColorCorrection::gamma_correct_(uint8_t value) const { if (this->gamma_table_ == nullptr) return value; - return static_cast((progmem_read_uint16(&this->gamma_table_[value]) + 128) / 257); + uint16_t table_value = progmem_read_uint16(&this->gamma_table_[value]); + uint8_t result = (table_value + 128) / 257; + if (result == 0 && table_value != 0) + return 1; + return result; } uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const { diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index 58d552031a7..5d372983d90 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 f5e4ebb83f1..ec129bdf705 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 67fd175ce67..4251565e855 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -219,6 +219,18 @@ LightColorValues LightCall::validate_() { this->set_flag_(FLAG_HAS_STATE); } + // 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) { diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 9d0181a05cf..82c00e23823 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -157,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_) { @@ -194,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); @@ -333,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 5efc05358b7..eafa161f51e 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; @@ -352,7 +356,7 @@ class LightState : public EntityBase, public Component { /// Default transition length for all transitions in ms. uint32_t default_transition_length_{}; /// Transition length to use for flash transitions. - uint32_t flash_transition_length_{}; + uint32_t flash_transition_length_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py /// Gamma correction factor for the light. float gamma_correct_{}; #ifdef USE_LIGHT_GAMMA_LUT diff --git a/esphome/components/light/types.py b/esphome/components/light/types.py index 9c1c7331d11..1778aa8410c 100644 --- a/esphome/components/light/types.py +++ b/esphome/components/light/types.py @@ -16,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 = { diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 76eabc2b712..0f42083cb52 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/lilygo_t5_47/touchscreen/__init__.py b/esphome/components/lilygo_t5_47/touchscreen/__init__.py index 93687846e2b..1e70f379a1a 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/lm75b/sensor.py b/esphome/components/lm75b/sensor.py index 335446b62f8..c59515b5b0a 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 00000000000..aadc1f3b2f9 --- /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 00000000000..0b15bf434c1 --- /dev/null +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -0,0 +1,474 @@ +// 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 00000000000..5b4a67b5664 --- /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 00000000000..4bfaa93ab7a --- /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 00000000000..8b211384a0c --- /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 00000000000..e1083e5fbe7 --- /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 00000000000..dc42aebce91 --- /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 9c918275227..b4e179c4a95 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/lock/__init__.py b/esphome/components/lock/__init__.py index 0a8ad58bc2d..a4a7b5237d1 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/logger/__init__.py b/esphome/components/logger/__init__.py index 77a875dd8fc..138db75ad10 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 @@ -58,7 +59,8 @@ from esphome.const import ( 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"] @@ -164,7 +166,7 @@ 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: @@ -187,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 = [] @@ -204,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( @@ -217,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 @@ -360,12 +362,13 @@ async def to_code(config: ConfigType) -> None: # pre_setup() switches on uart_ to decide which hardware to initialize # (e.g. UART0 vs USB_SERIAL_JTAG). Without this, uart_ is still the # default UART_SELECTION_UART0 and the wrong hardware gets initialized. - if CONF_HARDWARE_UART in config: - cg.add( - log.set_uart_selection( - HARDWARE_UART_TO_UART_SELECTION[config[CONF_HARDWARE_UART]] - ) - ) + # uart_ is UART0 in C++ except on LibreTiny where it is DEFAULT; skip the + # setter when the config matches it. + cpp_default_uart = DEFAULT if CORE.is_libretiny else UART0 + if ( + hardware_uart := config.get(CONF_HARDWARE_UART) + ) is not None and hardware_uart != cpp_default_uart: + cg.add(log.set_uart_selection(HARDWARE_UART_TO_UART_SELECTION[hardware_uart])) # pre_setup() sets global_logger and must run before any other code # that may call ESP_LOG* (e.g. setup_preferences contains ESP_LOGVV). cg.add(log.pre_setup()) @@ -410,10 +413,16 @@ 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() @@ -512,7 +521,7 @@ 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 @@ -553,7 +562,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]] @@ -578,7 +592,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): @@ -650,7 +669,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 6527b6aa8cd..bfc005070e7 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -201,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_RP2) || 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 69d8e6d32af..ae55f4145a9 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -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_; } @@ -163,7 +163,7 @@ class Logger final : public Component { #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 @@ -352,10 +352,10 @@ class Logger final : public Component { // 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_RP2) || defined(USE_ZEPHYR) - UARTSelection uart_{UART_SELECTION_UART0}; + UARTSelection uart_{UART_SELECTION_UART0}; // Must match cpp_default_uart in __init__.py #endif #ifdef USE_LIBRETINY - UARTSelection uart_{UART_SELECTION_DEFAULT}; + UARTSelection uart_{UART_SELECTION_DEFAULT}; // Must match cpp_default_uart in __init__.py #endif #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) bool main_task_recursion_guard_{false}; diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 05fc959ceb2..8579708559d 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -3,8 +3,10 @@ #include "esphome/components/esp32/crash_handler.h" #include +#include #include +#include #ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG #include @@ -15,8 +17,10 @@ #include #endif #endif - -#include "esp_idf_version.h" +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) +#include "esp_sleep.h" +#endif #include "freertos/FreeRTOS.h" #include @@ -76,12 +80,22 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { uart_config.parity = UART_PARITY_DISABLE; uart_config.stop_bits = UART_STOP_BITS_1; uart_config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; +#if SOC_UART_SUPPORT_XTAL_CLK + uart_config.source_clk = UART_SCLK_XTAL; +#else uart_config.source_clk = UART_SCLK_DEFAULT; +#endif uart_param_config(uart_num, &uart_config); // The logger only writes to UART, never reads, so use the minimum RX buffer. // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1; uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0); +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) + // Always flush before going to light sleep. Could be disabled for devices + // without TOP_PD or if source_clk = UART_SCLK_RTC + esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART); +#endif } void Logger::pre_setup() { diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5797b03ba72..ac71ba8e3bf 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 fe094f6e9e5..708ab883e76 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/select/__init__.py b/esphome/components/logger/select/__init__.py index 6ce663978e4..00f67422f35 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/lps22/sensor.py b/esphome/components/lps22/sensor.py index 08e97ee7b77..2eec2c586c5 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/motion.py b/esphome/components/lsm6ds/motion.py index 8c2c5198eab..064cd312a92 100644 --- a/esphome/components/lsm6ds/motion.py +++ b/esphome/components/lsm6ds/motion.py @@ -8,6 +8,7 @@ from esphome.components.const import ( ) 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 @@ -93,7 +94,7 @@ CONFIG_SCHEMA = ( # ── Code generation ────────────────────────────────────────────────────────── -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = await new_motion_component(config) # Let the motion platform handle sensor wiring, axis mapping, and polling diff --git a/esphome/components/lsm6ds/sensor.py b/esphome/components/lsm6ds/sensor.py index 980e84a2e9e..c0c37f8527b 100644 --- a/esphome/components/lsm6ds/sensor.py +++ b/esphome/components/lsm6ds/sensor.py @@ -11,6 +11,7 @@ from esphome.const import ( UNIT_CELSIUS, ) from esphome.cpp_generator import MockObj +from esphome.types import ConfigType from . import CONF_LSM6DS_ID, LSM6DSComponent @@ -28,7 +29,7 @@ CONFIG_SCHEMA = sensor.sensor_schema( ) -async def to_code(config): +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") diff --git a/esphome/components/ltr390/sensor.py b/esphome/components/ltr390/sensor.py index 37fceaf9844..c3ac90ad114 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/sensor.py b/esphome/components/ltr501/sensor.py index c1fa9009b3c..c2091a6336b 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 @@ -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( @@ -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/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 893415f0280..af09282e2d9 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 256bf4bb3af..2d2f1d6288b 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,4 +1,3 @@ -import functools import importlib from pathlib import Path import pkgutil @@ -58,7 +57,6 @@ from .defines import ( CONF_ALIGN_TO_LAMBDA_ID, CONF_ANIMATIONS, LOGGER, - add_lv_use, get_focused_widgets, get_lv_images_used, get_refreshed_widgets, @@ -75,7 +73,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static from .schemas import ( - BASE_PROPS, DISP_BG_SCHEMA, FULL_STYLE_SCHEMA, SET_STATE_SCHEMA, @@ -84,9 +81,10 @@ from .schemas import ( STYLE_SCHEMA, WIDGET_TYPES, any_widget_schema, + apply_style_driven_defines, container_schema, container_schema_value, - obj_dict, + theme_schema, ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code @@ -109,7 +107,6 @@ from .widgets import ( get_screen_active, set_obj_properties, ) -from .widgets.img import CONF_IMAGE # Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code @@ -215,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 ( @@ -444,6 +453,15 @@ async def to_code(configs): # 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: @@ -470,34 +488,16 @@ async def to_code(configs): # This must be done after all widgets are created styles_used = df.get_styles_used() - if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used): - add_lv_use(CONF_IMAGE) + 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) @@ -552,34 +552,6 @@ def add_hello_world(config): return config -@functools.cache -def _build_theme_schema( - widget_types: tuple[tuple[str, widgets.WidgetType], ...], -) -> 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() in - # schemas.py for why chained .extend() is avoided here. - return cv.Schema( - { - cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean, - **{ - cv.Optional(name): cv.Schema( - {**obj_dict(w), **FULL_STYLE_SCHEMA.schema} - ) - for name, w in widget_types - }, - } - ) - - -def _theme_schema(value: dict) -> dict: - return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value) - - FINAL_VALIDATE_SCHEMA = final_validation # The options accepted at the top level of an `lvgl:` block, on top of the base @@ -647,7 +619,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( 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_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, diff --git a/esphome/components/lvgl/animation.h b/esphome/components/lvgl/animation.h index 1e0abce3583..26bb433f874 100644 --- a/esphome/components/lvgl/animation.h +++ b/esphome/components/lvgl/animation.h @@ -21,12 +21,22 @@ class LvAnimationTiming { 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 { - value *= 2.0f; - if (value > 1.0f) - return 2.0f - value; - return value; + 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 { diff --git a/esphome/components/lvgl/animation.py b/esphome/components/lvgl/animation.py index 2b1500f2c47..95d45de5ea2 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -42,6 +42,7 @@ LvAnimationTimingRoundTrip = lvgl_ns.class_("LvAnimationTimingRoundTrip") LvAnimationTimingEaseInOut = lvgl_ns.class_("LvAnimationTimingEaseInOut") CONF_BOUNCE = "bounce" +CONF_PAUSE = "pause" def timing_class(name, extras=None): @@ -60,10 +61,20 @@ TIMING_SCHEMA = cv.maybe_simple_value( cv.typed_schema( dict( [ - timing_class("round_trip"), + 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=2.0): lv_positive_float}, + {cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float}, ), timing_class( "gravity", diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index b7c90a5c51a..c23a36c3892 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -5,9 +5,16 @@ from esphome import automation from esphome.automation import StatelessLambdaAction import esphome.codegen as cg 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_generator import StaticCastExpression, TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( @@ -23,11 +30,11 @@ from .defines import ( CONF_SHOW_SNOW, CONF_TOP_LAYER, PARTS, - StaticCastExpression, add_warning, get_focused_widgets, get_options, get_refreshed_widgets, + literal, ) from .layout import layout_validator from .lv_validation import lv_bool, lv_milliseconds, lv_rotation @@ -36,6 +43,7 @@ from .lvcode import ( UPDATE_EVENT, LambdaContext, LocalVariable, + LvConditional, LvglComponent, ReturnStatement, add_line_marks, @@ -376,6 +384,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 4f734fe20c7..73fc58736bb 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,12 +10,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import ( - CallExpression, - LambdaExpression, - MockObj, - MockObjClass, -) +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -33,6 +28,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 +114,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()) @@ -148,17 +152,6 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) -class StaticCastExpression(Expression): - __slots__ = ("type", "exp") - - def __init__(self, type: Any, exp: SafeExpType): - self.type = str(type) - self.exp = cg.safe_exp(exp) - - def __str__(self): - return f"static_cast<{self.type}>({self.exp})" - - def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -183,31 +176,6 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression) -> Expression: - """ - Given a lambda, either reduce to a simple expression or call it, possibly with parameters - from the surrounding context - :param lamb: - :return: - """ - expr = lamb.content.strip() - if expr.startswith("return") and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression - expr = cg.RawExpression(expr[6:-1].strip()) - # Don't cast if the return type is a class - if isinstance(lamb.return_type, MockObjClass): - return expr - return StaticCastExpression(lamb.return_type, expr) - # If lambda has parameters, call it with their names - # Parameter names come from hardcoded component code (like "x", "it", "event") - # not from user input, so they're safe to use directly - if lamb.parameters and lamb.parameters.parameters: - return CallExpression( - lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] - ) - return CallExpression(lamb) - - class LValidator: """ A validator for a particular type used in LVGL. Usable in configs as a validator, also @@ -474,6 +442,7 @@ LV_ANIM = LvConstant( LV_GRAD_DIR = LvConstant("LV_GRAD_DIR_", "NONE", "HOR", "VER") LV_DITHER = LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF") +LV_GRAD_EXTEND = LvConstant("LV_GRAD_EXTEND_", "PAD", "REPEAT", "REFLECT") LV_LOG_LEVELS = { "VERBOSE": "TRACE", @@ -576,6 +545,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", @@ -603,10 +587,6 @@ OBJ_FLAGS = ( "send_draw_task_events", "widget_1", "widget_2", - "user_1", - "user_2", - "user_3", - "user_4", ) LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) @@ -880,7 +860,7 @@ LV_COLOR_FORMATS = ( LV_DEFINES = ( "LV_USE_FREERTOS_TASK_NOTIFY", "LV_DRAW_BUF_STRIDE_ALIGN", "LV_USE_DRAW_SW", "LV_DRAW_SW_DRAW_UNIT_CNT", - "LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D", + "LV_DRAW_SW_COMPLEX", "LV_USE_DRAW_SW_COMPLEX_GRADIENTS", "LV_USE_DRAW_PXP", "LV_USE_PXP_DRAW_THREAD", "LV_USE_DRAW_G2D", "LV_USE_G2D_DRAW_THREAD", "LV_VG_LITE_USE_BOX_SHADOW", "LV_VG_LITE_THORVG_16PIXELS_ALIGN", "LV_LOG_USE_TIMESTAMP", "LV_LOG_USE_FILE_LINE", "LV_USE_OBJ_ID_BUILTIN", "LV_USE_OBJ_PROPERTY_NAME", "LV_ATTRIBUTE_MEM_ALIGN_SIZE", "LV_FONT_MONTSERRAT_14", "LV_USE_FONT_PLACEHOLDER", "LV_WIDGETS_HAS_DEFAULT_VALUE", "LV_USE_ARCLABEL", diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index 2f1be207722..8db183fabe2 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -13,18 +13,40 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import ( + CONF_END_ANGLE, CONF_GRADIENTS, CONF_OPA, + CONF_START_ANGLE, LV_DITHER, + LV_GRAD_EXTEND, add_define, add_lv_use, add_warning, ) -from .lv_validation import lv_color, lv_percentage, opacity +from .lv_validation import ( + lv_angle_degrees, + lv_color, + lv_percentage, + opacity, + pixels_or_percent, +) from .lvcode import lv from .types import lv_color_t, lv_gradient_t, lv_opa_t CONF_STOPS = "stops" +CONF_LINEAR = "linear" +CONF_RADIAL = "radial" +CONF_CONICAL = "conical" +CONF_EXTEND = "extend" +CONF_FROM_X = "from_x" +CONF_FROM_Y = "from_y" +CONF_TO_X = "to_x" +CONF_TO_Y = "to_y" +CONF_CENTER_X = "center_x" +CONF_CENTER_Y = "center_y" +CONF_FOCAL_X = "focal_x" +CONF_FOCAL_Y = "focal_y" +CONF_FOCAL_RADIUS = "focal_radius" def min_stops(value): @@ -33,27 +55,109 @@ def min_stops(value): return value +STOPS_SCHEMA = cv.All( + [ + cv.Schema( + { + cv.Required(CONF_COLOR): lv_color, + cv.Optional(CONF_OPA, default=1.0): opacity, + cv.Required(CONF_POSITION): lv_percentage, + } + ) + ], + min_stops, +) + +LINEAR_SCHEMA = cv.Schema( + { + cv.Required(CONF_FROM_X): pixels_or_percent, + cv.Required(CONF_FROM_Y): pixels_or_percent, + cv.Required(CONF_TO_X): pixels_or_percent, + cv.Required(CONF_TO_Y): pixels_or_percent, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + +RADIAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_CENTER_X): pixels_or_percent, + cv.Required(CONF_CENTER_Y): pixels_or_percent, + cv.Required(CONF_TO_X): pixels_or_percent, + cv.Required(CONF_TO_Y): pixels_or_percent, + cv.Optional(CONF_FOCAL_X): pixels_or_percent, + cv.Optional(CONF_FOCAL_Y): pixels_or_percent, + # No default: gradient_validator() must be able to tell whether this was actually + # given, to require it alongside focal_x/focal_y rather than silently drop it. + # LVGL's lv_grad_radial_set_focal() takes this as a scalar, not lv_pct() - + # unlike every other coordinate here, a percentage is not accepted. + cv.Optional(CONF_FOCAL_RADIUS): cv.positive_int, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + +CONICAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_CENTER_X): pixels_or_percent, + cv.Required(CONF_CENTER_Y): pixels_or_percent, + cv.Optional(CONF_START_ANGLE, default=0): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE, default=360): lv_angle_degrees, + cv.Optional(CONF_EXTEND, default="PAD"): LV_GRAD_EXTEND.one_of, + } +) + + +def gradient_validator(config): + direction = config[CONF_DIRECTION] + for gradient_direction, key in ( + ("LINEAR", CONF_LINEAR), + ("RADIAL", CONF_RADIAL), + ("CONICAL", CONF_CONICAL), + ): + if direction == gradient_direction: + if key not in config: + raise cv.Invalid( + f"'{key}' is required for {gradient_direction} gradient direction" + ) + elif key in config: + raise cv.Invalid( + f"'{key}' is only valid with 'direction: {gradient_direction}'" + ) + if CONF_RADIAL in config: + radial = config[CONF_RADIAL] + has_focal_x = CONF_FOCAL_X in radial + has_focal_y = CONF_FOCAL_Y in radial + has_focal_radius = CONF_FOCAL_RADIUS in radial + if has_focal_x != has_focal_y or (has_focal_radius and not has_focal_x): + raise cv.Invalid( + "'focal_x', 'focal_y' and 'focal_radius' must be specified together " + "in 'radial'" + ) + return config + + GRADIENT_SCHEMA = cv.ensure_list( - cv.Schema( - { - cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), - cv.Required(CONF_DIRECTION): cv.one_of( - "HOR", "HORIZONTAL", "VER", "VERTICAL", upper=True - ), - cv.Optional(CONF_DITHER): LV_DITHER.one_of, - cv.Required(CONF_STOPS): cv.All( - [ - cv.Schema( - { - cv.Required(CONF_COLOR): lv_color, - cv.Optional(CONF_OPA, default=1.0): opacity, - cv.Required(CONF_POSITION): lv_percentage, - } - ) - ], - min_stops, - ), - } + cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ID): cv.declare_id(lv_gradient_t), + cv.Required(CONF_DIRECTION): cv.one_of( + "HOR", + "HORIZONTAL", + "VER", + "VERTICAL", + "LINEAR", + "RADIAL", + "CONICAL", + upper=True, + ), + cv.Optional(CONF_DITHER): LV_DITHER.one_of, + cv.Optional(CONF_LINEAR): LINEAR_SCHEMA, + cv.Optional(CONF_RADIAL): RADIAL_SCHEMA, + cv.Optional(CONF_CONICAL): CONICAL_SCHEMA, + cv.Required(CONF_STOPS): STOPS_SCHEMA, + } + ), + gradient_validator, ) ) @@ -65,15 +169,60 @@ async def gradients_to_code(config): add_warning( "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" ) + if any( + x[CONF_DIRECTION] in ("LINEAR", "RADIAL", "CONICAL") + for x in config.get(CONF_GRADIENTS, ()) + ): + # LVGL's software renderer only draws these gradient types when this is enabled; without + # it they silently fall back to a plain horizontal gradient. + add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS") for gradient in config.get(CONF_GRADIENTS, ()): var = MockObj(cg.new_Pvariable(gradient[CONF_ID]), "->") idbase = gradient[CONF_ID].id stops = sorted(gradient[CONF_STOPS], key=itemgetter(CONF_POSITION)) max_stops = max(max_stops, len(stops)) - if gradient[CONF_DIRECTION].startswith("VER"): + direction = gradient[CONF_DIRECTION] + if direction.startswith("VER"): lv.grad_vertical_init(var) - else: + elif direction.startswith("HOR"): lv.grad_horizontal_init(var) + elif direction == "LINEAR": + linear = gradient[CONF_LINEAR] + lv.grad_linear_init( + var, + await pixels_or_percent.process(linear[CONF_FROM_X]), + await pixels_or_percent.process(linear[CONF_FROM_Y]), + await pixels_or_percent.process(linear[CONF_TO_X]), + await pixels_or_percent.process(linear[CONF_TO_Y]), + await LV_GRAD_EXTEND.process(linear[CONF_EXTEND]), + ) + elif direction == "RADIAL": + radial = gradient[CONF_RADIAL] + lv.grad_radial_init( + var, + await pixels_or_percent.process(radial[CONF_CENTER_X]), + await pixels_or_percent.process(radial[CONF_CENTER_Y]), + await pixels_or_percent.process(radial[CONF_TO_X]), + await pixels_or_percent.process(radial[CONF_TO_Y]), + await LV_GRAD_EXTEND.process(radial[CONF_EXTEND]), + ) + if CONF_FOCAL_X in radial: + lv.grad_radial_set_focal( + var, + await pixels_or_percent.process(radial[CONF_FOCAL_X]), + await pixels_or_percent.process(radial[CONF_FOCAL_Y]), + radial.get(CONF_FOCAL_RADIUS, 0), + ) + elif direction == "CONICAL": + conical = gradient[CONF_CONICAL] + lv.grad_conical_init( + var, + await pixels_or_percent.process(conical[CONF_CENTER_X]), + await pixels_or_percent.process(conical[CONF_CENTER_Y]), + await lv_angle_degrees.process(conical[CONF_START_ANGLE]), + await lv_angle_degrees.process(conical[CONF_END_ANGLE]), + await LV_GRAD_EXTEND.process(conical[CONF_EXTEND]), + ) stop_colors = cg.static_const_array( ID(idbase + "_colors_", type=lv_color_t), [await lv_color.process(x[CONF_COLOR]) for x in stops], diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 42352b96023..6f86e49e511 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -16,7 +16,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -33,9 +33,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, - StaticCastExpression, add_lv_use, - call_lambda, get_esphome_fonts_used, get_lv_fonts_used, get_lv_images_used, diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index de005937730..850b63a26f4 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 b66a9044375..2c988473a93 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -208,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) { @@ -444,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)); @@ -525,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(); @@ -551,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; @@ -963,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; } @@ -994,8 +1059,9 @@ static void *lv_alloc_draw_buf(size_t size, bool internal) { void *buffer; size = LV_ROUND_UP(size, LV_DRAW_BUF_ALIGN); buffer = heap_caps_aligned_alloc(LV_DRAW_BUF_ALIGN, size, internal ? MALLOC_CAP_8BIT : cap_bits); // NOLINT - if (buffer == nullptr) + if (buffer == nullptr) { ESP_LOGW(esphome::lvgl::TAG, "Failed to allocate %zu bytes for %sdraw buffer", size, internal ? "internal " : ""); + } return buffer; } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 9221ab9542e..8b7397c4cd2 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 @@ -116,6 +120,18 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector images 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 /** * @@ -135,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 { @@ -241,10 +263,11 @@ class LvglComponent final : 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) @@ -492,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: @@ -499,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/schemas.py b/esphome/components/lvgl/schemas.py index dd4f71a3460..bbc977dca51 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,4 +1,5 @@ from collections.abc import Callable +import functools from typing import Any from esphome import config_validation as cv @@ -8,6 +9,7 @@ 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, @@ -69,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 = {} @@ -591,6 +593,96 @@ def obj_schema(widget_type: WidgetType) -> cv.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( { @@ -634,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. diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index e36357328c2..dafdd91eb50 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -50,19 +50,10 @@ class LVGLSelect final : 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 59115055550..ad420283279 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -10,11 +10,18 @@ from .defines import ( 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 @@ -86,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/trigger.py b/esphome/components/lvgl/trigger.py index 5f524969e28..56dcf81a797 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 61efe385e69..cc8d9438a97 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): @@ -112,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 968db46adc5..a524fe761f9 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -16,7 +16,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.cpp_generator import MockObj, call_lambda from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression @@ -42,7 +42,6 @@ from ..defines import ( STATES, LValidator, add_lv_use, - call_lambda, get_styles_used, get_theme_widget_map, get_widget_map, @@ -190,18 +189,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) @@ -230,7 +218,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: @@ -267,6 +255,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. diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index bcd2d2ae597..65516513a6c 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -15,6 +15,7 @@ from ..defines import ( from ..types import LvCompound, LvType from . import Widget, WidgetType, get_widgets from .buttonmatrix import CONF_BUTTONMATRIX +from .label import CONF_LABEL from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -49,7 +50,7 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX + return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL async def to_code(self, w: Widget, config: dict): add_lv_use("KEY_LISTENER") diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index 5ac92f2717a..54c9819d2b7 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 00000000000..7711e8bfe4f --- /dev/null +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -0,0 +1,557 @@ +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) + await _wait_list_triggers_completed() + + 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) + await _wait_list_triggers_completed() + + 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) + await _wait_list_triggers_completed() + + 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) + await _wait_list_triggers_completed() + + 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/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index df76ab6bb0d..59af9168aad 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -10,6 +10,7 @@ from ..types import lv_obj_t from . import Widget, WidgetType from .canvas import CONF_CANVAS from .img import CONF_IMAGE +from .label import CONF_LABEL CONF_QRCODE = "qrcode" CONF_DARK_COLOR = "dark_color" @@ -41,7 +42,7 @@ class QrCodeType(WidgetType): ) def get_uses(self): - return CONF_CANVAS, CONF_IMAGE + return CONF_CANVAS, CONF_IMAGE, CONF_LABEL async def to_code(self, w: Widget, config): await w.set_property( diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py new file mode 100644 index 00000000000..f000ea18466 --- /dev/null +++ b/esphome/components/lvgl/widgets/table.py @@ -0,0 +1,279 @@ +from contextlib import ExitStack + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_COLUMNS, 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_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 ee252ecf0b2..77c88c48ff8 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -28,6 +28,7 @@ from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec +from .label import CONF_LABEL from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -74,7 +75,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON + return CONF_BUTTONMATRIX, TYPE_FLEX, CONF_BUTTON, CONF_LABEL async def to_code(self, w: Widget, config: dict): await w.set_property( diff --git a/esphome/components/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index a1c197b3811..6404bcf64c9 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 22ab73e9015..09398876d4b 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/light/__init__.py b/esphome/components/m5stack_8angle/light/__init__.py index 806ecaabf4d..5c4863acf76 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/sensor/__init__.py b/esphome/components/m5stack_8angle/sensor/__init__.py index 2132eaa4c22..87d14252412 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/mapping/__init__.py b/esphome/components/mapping/__init__.py index 3c7d78a27bc..cd846877ae7 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 868b1492115..2e43eaf7e22 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -1,9 +1,10 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import key_provider -from esphome.components.const import CONF_ROWS +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, 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"] @@ -20,14 +21,12 @@ MatrixKeyTrigger = matrix_keypad_ns.class_( ) CONF_KEYPAD_ID = "keypad_id" -CONF_COLUMNS = "columns" -CONF_KEYS = "keys" CONF_DEBOUNCE_TIME = "debounce_time" 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 +61,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 8e63ed43ce4..6c6e0aad732 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/max17043/sensor.py b/esphome/components/max17043/sensor.py index ebb045dfcea..67fb8aa5b7b 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/sensor.py b/esphome/components/max31855/sensor.py index 35ae28d04ca..a52f45a18f0 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.cpp b/esphome/components/max31856/max31856.cpp index 4062d21beee..b5bad8ef743 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -23,8 +23,10 @@ void MAX31856Sensor::setup() { void MAX31856Sensor::dump_config() { LOG_SENSOR("", "MAX31856", this); LOG_PIN(" CS Pin: ", this->cs_); - ESP_LOGCONFIG(TAG, " Mains Filter: %s", - (filter_ == FILTER_60HZ ? "60 Hz" : (filter_ == FILTER_50HZ ? "50 Hz" : "Unknown!"))); + ESP_LOGCONFIG( + TAG, " Mains Filter: %s", + (filter_ == FILTER_60HZ ? LOG_STR_LITERAL("60 Hz") + : (filter_ == FILTER_50HZ ? LOG_STR_LITERAL("50 Hz") : LOG_STR_LITERAL("Unknown!")))); if (this->thermocouple_type_ < 0 || this->thermocouple_type_ > 7) { ESP_LOGCONFIG(TAG, " Thermocouple Type: Unknown"); } else { diff --git a/esphome/components/max31856/sensor.py b/esphome/components/max31856/sensor.py index 679e02b11d5..43a2e18db87 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.cpp b/esphome/components/max31865/max31865.cpp index 220fb4e7041..e5a6fca8fb6 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -80,12 +80,14 @@ void MAX31865Sensor::dump_config() { LOG_SENSOR("", "MAX31865", this); LOG_PIN(" CS Pin: ", this->cs_); LOG_UPDATE_INTERVAL(this); - ESP_LOGCONFIG(TAG, - " Reference Resistance: %.2fΩ\n" - " RTD: %u-wire %.2fΩ\n" - " Mains Filter: %s", - reference_resistance_, rtd_wires_, rtd_nominal_resistance_, - (filter_ == FILTER_60HZ ? "60 Hz" : (filter_ == FILTER_50HZ ? "50 Hz" : "Unknown!"))); + ESP_LOGCONFIG( + TAG, + " Reference Resistance: %.2fΩ\n" + " RTD: %u-wire %.2fΩ\n" + " Mains Filter: %s", + reference_resistance_, rtd_wires_, rtd_nominal_resistance_, + (filter_ == FILTER_60HZ ? LOG_STR_LITERAL("60 Hz") + : (filter_ == FILTER_50HZ ? LOG_STR_LITERAL("50 Hz") : LOG_STR_LITERAL("Unknown!")))); } void MAX31865Sensor::read_data_() { diff --git a/esphome/components/max31865/sensor.py b/esphome/components/max31865/sensor.py index d4498b062f8..167a0997e45 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/sensor.py b/esphome/components/max44009/sensor.py index 5aea7f0be2e..88673c5d00a 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/sensor.py b/esphome/components/max6675/sensor.py index e42abb68d1c..94d857cab95 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 e9fae4cceb7..5e45d718999 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/output/__init__.py b/esphome/components/max6956/output/__init__.py index 352ba04a95e..f92bbb762a0 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/max7219/display.py b/esphome/components/max7219/display.py index abb20702bd2..b21f66b553d 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/max7219digit/display.py b/esphome/components/max7219digit/display.py index df2423b0d0e..54711263dda 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/max9611/sensor.py b/esphome/components/max9611/sensor.py index b3a73d8c10c..9332274a955 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 8ff938114a5..3d1480a6f7d 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/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index b71d57498a2..5f4b7276d83 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/mcp23017/__init__.py b/esphome/components/mcp23017/__init__.py index e5cc1856ebb..474d75f6ff6 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/mcp23s08/__init__.py b/esphome/components/mcp23s08/__init__.py index 312da79b75c..ffc51b81468 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/mcp23s17/__init__.py b/esphome/components/mcp23s17/__init__.py index 599bfa08515..d693a64ce84 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/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 76a3aabe3fa..755d86e4ea7 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/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index d34a77248ca..8bb8918f965 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/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index 41ccdd403a4..6d1bd5a970b 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/sensor/__init__.py b/esphome/components/mcp3008/sensor/__init__.py index 2576ef50e51..de31f81345f 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/mcp3204/__init__.py b/esphome/components/mcp3204/__init__.py index 612297f934b..5757bdbfa98 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/sensor/__init__.py b/esphome/components/mcp3204/sensor/__init__.py index 5f9aa9fdb62..728a1c0611a 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/mcp3221/sensor.py b/esphome/components/mcp3221/sensor.py index 993876c2c83..30e972d8082 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 f3ef6f49179..60cece67d74 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 e83a6847d69..cc53f9de7f7 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) { @@ -222,9 +319,12 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { if (!(this->read_16_(reg, &buf))) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); - ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); + ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? LOG_STR_LITERAL("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; } @@ -273,7 +377,8 @@ void Mcp4461Component::write_wiper_level_(uint8_t wiper, uint16_t value) { if (!(this->mcp4461_write_(this->get_wiper_address_(wiper), value, nonvolatile))) { this->error_code_ = MCP4461_STATUS_I2C_ERROR; this->status_set_warning(); - ESP_LOGW(TAG, "Error writing %swiper %u level %u", (wiper > 3) ? "nonvolatile " : "", wiper, value); + ESP_LOGW(TAG, "Error writing %swiper %u level %u", (wiper > 3) ? LOG_STR_LITERAL("nonvolatile ") : "", wiper, + value); } } @@ -335,6 +440,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; @@ -349,6 +460,10 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) { 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 +481,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; @@ -380,11 +501,18 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) { 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 +599,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 +632,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 a577a4b4824..933d92c1fa5 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 @@ -86,6 +96,11 @@ class Mcp4461Component final : 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 final : 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 final : 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 final : 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 0d145d81d30..db1a1e6a29f 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 00000000000..4be317b2f8f --- /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 3892372cabd..5c373ddc7d2 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -66,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 20d81d825a1..c8d1ef1ec51 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -36,6 +36,9 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented 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 da3244be84a..f48bdde681f 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/output/__init__.py b/esphome/components/mcp4728/output/__init__.py index 6f4a41510f2..e8cb4c47d69 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/mcp47a1/output.py b/esphome/components/mcp47a1/output.py index ebd597cfeb4..91bb3b47dbb 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/sensor.py b/esphome/components/mcp9600/sensor.py index 65ae5f2eecf..5542ffaa6c7 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/sensor.py b/esphome/components/mcp9808/sensor.py index ba6718ca569..1daaa9c131a 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 1710b00e662..6a928d16829 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/mdns/__init__.py b/esphome/components/mdns/__init__.py index 3670098bcfc..c8020104b37 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component +from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( @@ -9,6 +9,7 @@ from esphome.const import ( CONF_PROTOCOL, CONF_SERVICE, CONF_SERVICES, + CONF_WIFI, PlatformFramework, ) from esphome.core import CORE, Lambda, coroutine_with_priority @@ -31,7 +32,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 +63,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 @@ -71,7 +72,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType: that never initializes. """ if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2): - return config + return full_config = fv.full_config.get() has_wifi = "wifi" in full_config has_ethernet = CORE.is_rp2 and "ethernet" in full_config @@ -81,7 +82,6 @@ def _require_network_interface(config: ConfigType) -> ConfigType: "mdns on this platform requires a network interface — " f"add a {options} component to your configuration." ) - return config CONFIG_SCHEMA = cv.All( @@ -118,7 +118,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 +173,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,13 +185,15 @@ 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) + # No MDNS global in the build; mdns_esp8266.cpp owns a guarded MDNSResponder + cg.add_build_flag("-DNO_GLOBAL_MDNS") elif CORE.is_rp2: cg.add_library("LEAmDNS", None) @@ -209,7 +211,16 @@ async def to_code(config): 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.12.0") + # ESPHome only advertises; the browse APIs are unused + add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) + # The mdns console CLI is never used by ESPHome + add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_CONSOLE_CLI", False) + if CONF_WIFI not in CORE.config: + # Without WiFi the predefined STA/AP interface handlers are dead + # code; disabling them lets mdns build without the WiFi stack. + add_idf_sdkconfig_option("CONFIG_MDNS_PREDEF_NETIF_STA", False) + add_idf_sdkconfig_option("CONFIG_MDNS_PREDEF_NETIF_AP", False) cg.add_define("USE_MDNS") diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index f6d57866756..0e600d3bac1 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -13,8 +13,47 @@ namespace esphome::mdns { +// Main-loop calls into LEAmDNS that send (update() and close(); begin(), addService() and +// the scheduled restart never reach a send) can yield inside UdpContext::sendTimeout(); a +// packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides +// free the same tx pbufs (#18760). Received packets stay queued during such a call and are +// processed from the main loop afterwards. +class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder { + public: + void update_guarded() { this->run_guarded_(&GuardedMDNSResponder::update); } + void close_guarded() { this->run_guarded_(&GuardedMDNSResponder::close); } + + private: + void run_guarded_(bool (GuardedMDNSResponder::*fn)()) { + UdpContext *ctx = this->m_pUDPContext; + if (ctx == nullptr) { + (this->*fn)(); + return; + } + // Set every time: a restart replaces the context together with its stock handler. Only + // begin() and the scheduled netif callback restart, never update() or close(), so the + // context cannot change underneath this call. + ctx->onRx([this]() { + if (!this->in_loop_call_) { + this->_callProcess(); + } + }); + this->in_loop_call_ = true; + (this->*fn)(); + // close() releases the context; a yield in here queues further packets for this loop too + while (this->m_pUDPContext != nullptr && this->m_pUDPContext->next()) { + this->_parseMessage(); + } + this->in_loop_call_ = false; + } + + volatile bool in_loop_call_{false}; +}; + +static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static void register_esp8266(MDNSComponent *, StaticVector &services) { - MDNS.begin(App.get_name().c_str()); + mdns_responder.begin(App.get_name().c_str()); for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is @@ -30,10 +69,10 @@ 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_responder.update_guarded(); + }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } #endif @@ -69,7 +120,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: #endif void MDNSComponent::on_shutdown() { - MDNS.close(); + mdns_responder.close_guarded(); delay(10); } diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index 7dce74117a9..6c3eef912e1 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -122,7 +122,7 @@ void MediaPlayerCall::perform() { ESP_LOGV(TAG, " Volume: %.2f", this->volume_.value()); } if (this->announcement_.has_value()) { - ESP_LOGV(TAG, " Announcement: %s", this->announcement_.value() ? "yes" : "no"); + ESP_LOGV(TAG, " Announcement: %s", this->announcement_.value() ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")); } this->parent_->control(*this); } diff --git a/esphome/components/media_source/__init__.py b/esphome/components/media_source/__init__.py index 43256db4afd..c9dab7e4d23 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.cpp b/esphome/components/mhz19/mhz19.cpp index ff518808d9e..707d952f83b 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -143,8 +143,6 @@ void MHZ19Component::dump_config() { ESP_LOGCONFIG(TAG, "MH-Z19:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); - this->check_uart_settings(9600); - if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { ESP_LOGCONFIG(TAG, " Automatic baseline calibration enabled on boot"); } else if (this->abc_boot_logic_ == MHZ19_ABC_DISABLED) { diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index b7d0ad1998e..5852686608c 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"] @@ -77,8 +80,16 @@ CONFIG_SCHEMA = ( .extend(uart.UART_DEVICE_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "mhz19", + baud_rate=9600, + data_bits=8, + parity="NONE", + stop_bits=1, +) -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 +140,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 +167,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 c427f28028e..092c4977ce1 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -166,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): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) @@ -432,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( @@ -555,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 = [] @@ -573,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( @@ -602,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/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 237d72229d2..cf239be6960 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 @@ -35,21 +37,34 @@ static const UBaseType_t INFERENCE_TASK_PRIORITY = 3; enum EventGroupBits : uint32_t { 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,7 +129,7 @@ void MicroWakeWord::setup() { return; } std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (this->ring_buffer_.use_count() > 1) { + if (temp_ring_buffer != nullptr) { // 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. @@ -176,6 +191,20 @@ void MicroWakeWord::inference_task(void *params) { xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING); 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; + } + 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(); @@ -232,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) { @@ -270,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); @@ -287,9 +446,9 @@ void MicroWakeWord::loop() { xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); } - if ((event_group_bits & EventGroupBits::TASK_STOPPED)) { + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) { ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); - this->inference_task_.deallocate(); xEventGroupClearBits(this->event_group_, ALL_BITS); xQueueReset(this->detection_queue_); this->set_state_(State::STOPPED); diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index aebb5b25954..03f4a86fd47 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -66,6 +66,32 @@ class MicroWakeWord final : public Component 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); @@ -85,6 +111,7 @@ class MicroWakeWord final : 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_; @@ -119,6 +146,13 @@ class MicroWakeWord final : public Component /// @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 a spectrogram feature from an input buffer of audio samples. The frontend buffers samples 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 00000000000..a7326ab77a0 --- /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 00000000000..0f0e08c7182 --- /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 1cdc06b3520..72984f04fb2 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 07ba78d1f44..1cb9d6eba54 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 b4623522292..ff06d0b913d 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 63b127e63d8..68b5b9aca69 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/number/__init__.py b/esphome/components/micronova/number/__init__.py index bcc972c5a9d..d33bb150cef 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/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index e53c49aca5c..6091718d658 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/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index e149ee3ce39..1f57497ad70 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/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 33d0779eae1..d6b94c437fe 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/microphone/__init__.py b/esphome/components/microphone/__init__.py index 6b5ee8c3e19..9a3f5b43e7b 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/microphone_source.h b/esphome/components/microphone/microphone_source.h index 7be3b8cdb59..d7a33524322 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -48,7 +48,7 @@ class MicrophoneSource final { template void add_data_callback(F &&data_callback) { this->mic_->add_data_callback([this, data_callback](const std::vector &data) { if (this->enabled_ || this->passive_) { - if (this->processed_samples_.use_count() == 0) { + if (this->processed_samples_ == nullptr) { // Create vector if its unused this->processed_samples_ = std::make_shared>(); } diff --git a/esphome/components/mics_4514/sensor.py b/esphome/components/mics_4514/sensor.py index 09329ebfcf1..3ba560c781a 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 77bb9bbe86a..30771d25ca2 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/log.h" #include "ac_adapter.h" @@ -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 4545743564c..4a888ee8ff2 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#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 b595a018b38..9572ec6c650 100644 --- a/esphome/components/midea/ac_automations.h +++ b/esphome/components/midea/ac_automations.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#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 e55afedd8a3..24bbfe76b0b 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -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 089928902e2..9977d2088ff 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#if (defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)) || defined(USE_ESP_IDF) // MideaUART #include @@ -61,4 +61,4 @@ class AirConditioner final : public ApplianceBase #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 b0c102af6dc..07ad02d3afc 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import climate, remote_transmitter, sensor, uart +from esphome.components import climate, remote_base, remote_transmitter, sensor, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode from esphome.components.remote_base import CONF_TRANSMITTER_ID import esphome.config_validation as cv @@ -153,7 +153,6 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), - cv.only_with_arduino, cv.only_on( [ PLATFORM_ESP32, @@ -281,6 +280,7 @@ async def to_code(config): cg.add(var.set_response_timeout(config[CONF_TIMEOUT].total_milliseconds)) cg.add(var.set_request_attempts(config[CONF_NUM_ATTEMPTS])) if CONF_TRANSMITTER_ID in config: + remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) cg.add(var.set_transmitter(transmitter_)) @@ -305,7 +305,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 ecf3fa1c1a2..e54df1fd70c 100644 --- a/esphome/components/midea/ir_transmitter.h +++ b/esphome/components/midea/ir_transmitter.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY) +#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 cbf5fae6fe0..e1b2b56ada4 100644 --- a/esphome/components/midea_ir/climate.py +++ b/esphome/components/midea_ir/climate.py @@ -1,7 +1,8 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base 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,10 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MideaIR).extend( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: + # midea_ir uses MideaProtocol from C++ and auto-loads coolix, whose coolix.cpp uses + # CoolixProtocol even when no coolix climate is configured + remote_base.request_protocol("midea") + remote_base.request_protocol("coolix") var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index e5bb3d413d7..b23982655a3 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -53,6 +53,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import mipi_dsi_ns, models from .models import DsiDriverChip @@ -85,7 +86,7 @@ COLOR_DEPTHS = { } -def model_schema(config): +def model_schema(config: ConfigType) -> cv.All: model = MODELS[config[CONF_MODEL].upper()] transform = model.transform_schema() # CUSTOM model will need to provide a custom init sequence @@ -148,7 +149,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -175,7 +176,7 @@ def _config_schema(config): 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 @@ -183,14 +184,13 @@ 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")) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 0ff934ae94a..0150cc25442 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -35,8 +35,8 @@ void MipiDsi::setup() { .bus_id = 0, // index from 0, specify the DSI host to use .num_data_lanes = this->lanes_, // Number of data lanes to use, can't set a value that exceeds the chip's capability - .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, // Clock source for the DPHY - .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps + // phy_clk_src left at 0 to enable runtime auto-select. + .lane_bit_rate_mbps = this->lane_bit_rate_, // Bit rate of the data lanes, in Mbps }; auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_); if (err != ESP_OK) { @@ -237,8 +237,9 @@ void MipiDsi::write_to_display_(int x_start, int y_start, int w, int h, const ui xSemaphoreTake(this->io_lock_, portMAX_DELAY); } } - if (err != ESP_OK) + if (err != ESP_OK) { ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); + } } bool MipiDsi::check_buffer_() { diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index ebe930d37a7..b91528160e2 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 @@ -11,7 +12,12 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD -from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S3, only_on_variant +from esphome.components.esp32 import ( + VARIANT_ESP32P4, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + only_on_variant, +) from esphome.components.mipi import ( COLOR_ORDERS, CONF_DE_PIN, @@ -72,6 +78,7 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.final_validate import full_config +from esphome.types import ConfigType from . import models from .models import RgbDriverChip @@ -97,7 +104,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. @@ -112,14 +119,14 @@ 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 = model.transform_schema() # RPI model does not use an init sequence, indicates with empty list @@ -213,7 +220,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema) -def _config_schema(config): +def _config_schema(config: ConfigType) -> ConfigType: config = cv.Schema( { cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), @@ -224,7 +231,7 @@ def _config_schema(config): config = cv.All( schema, cv.only_on_esp32, - only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]), + only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]), )(config) model = MODELS[config[CONF_MODEL].upper()] model.check_requirements() @@ -248,7 +255,7 @@ def _config_schema(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 @@ -260,13 +267,12 @@ 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, _pad_width, _pad_height = ( model.get_dimensions(config) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index b07460fdba9..c11044c2882 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -1,11 +1,11 @@ -#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "mipi_rgb.h" #include "esphome/core/gpio.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include -#include +#include #include namespace esphome::mipi_rgb { @@ -177,11 +177,6 @@ void MipiRgb::common_setup_() { ESP_LOGCONFIG(TAG, "MipiRgb setup complete"); } -void MipiRgb::loop() { - if (this->handle_ != nullptr) - esp_lcd_rgb_panel_restart(this->handle_); -} - void MipiRgb::update() { if (this->is_failed()) return; @@ -243,8 +238,9 @@ void MipiRgb::write_to_display_(int x_start, int y_start, int w, int h, const ui ptr += stride; // next line } } - if (err != ESP_OK) + if (err != ESP_OK) { ESP_LOGE(TAG, "lcd_lcd_panel_draw_bitmap failed: %s", esp_err_to_name(err)); + } } bool MipiRgb::check_buffer_() { @@ -345,7 +341,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()); @@ -400,4 +396,5 @@ void MipiRgb::dump_config() { } } // namespace esphome::mipi_rgb -#endif // defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#endif // defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || + // defined(USE_ESP32_VARIANT_ESP32S31) diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 1480004833e..f528943c1b5 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -1,9 +1,9 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "esphome/core/gpio.h" #include "esphome/components/display/display.h" -#include "esp_lcd_panel_ops.h" +#include #ifdef USE_SPI #include "esphome/components/spi/spi.h" #endif @@ -25,7 +25,12 @@ class MipiRgb : public display::Display { public: MipiRgb(int width, int height) : width_(width), height_(height) {} void setup() override; - void loop() override; +#ifdef USE_ESP32_VARIANT_ESP32S3 + void loop() override { + if (this->handle_ != nullptr) + esp_lcd_rgb_panel_restart(this->handle_); + } +#endif void update() override; 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, diff --git a/esphome/components/mipi_rgb/models/elecrow.py b/esphome/components/mipi_rgb/models/elecrow.py new file mode 100644 index 00000000000..acc36beb74d --- /dev/null +++ b/esphome/components/mipi_rgb/models/elecrow.py @@ -0,0 +1,28 @@ +from . import RgbDriverChip + +# fmt: off +RgbDriverChip( + "CROWPANEL-ADVANCE-7", + requires={"psram"}, + initsequence=(), + pclk_frequency="20MHz", + hsync_pulse_width=4, + hsync_front_porch=8, + hsync_back_porch=8, + vsync_pulse_width=4, + vsync_front_porch=8, + vsync_back_porch=8, + pclk_inverted=True, + color_order="RGB", + width=800, + height=480, + de_pin=42, + hsync_pin=40, + vsync_pin=41, + pclk_pin=39, + data_pins={ + "red": [7, 17, 18, 3, 46], + "green": [9, 10, 11, 12, 13, 14], + "blue": [21, 47, 48, 45, 38], + }, +) diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index a20e9d1c010..cad5dc8e20d 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -8,7 +8,7 @@ SDIR_CMD = 0xC7 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: diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 246db237b1a..e8b54da5c79 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -53,8 +53,9 @@ from esphome.const import ( 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 @@ -110,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, @@ -132,7 +133,7 @@ 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 = model.transform_schema() @@ -238,7 +239,7 @@ def model_schema(config): @model_schema_extractor(MODELS, model_schema, extra={CONF_BUS_MODE: TYPE_SINGLE}) -def customise_schema(config): +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 @@ -305,7 +306,7 @@ def customise_schema(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]] @@ -341,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. @@ -394,7 +395,7 @@ 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, add_madctl=False, add_reset=True) diff --git a/esphome/components/mipi_spi/mipi_spi.cpp b/esphome/components/mipi_spi/mipi_spi.cpp index 2eec3b12d17..b2658de6e82 100644 --- a/esphome/components/mipi_spi/mipi_spi.cpp +++ b/esphome/components/mipi_spi/mipi_spi.cpp @@ -25,17 +25,21 @@ void internal_dump_config(const char *model, int width, int height, int offset_w " SPI Bus width: %d", model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)), YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(has_hardware_rotation), YESNO(invert_colors), - (madctl & MADCTL_BGR) ? "BGR" : "RGB", display_bits, is_big_endian ? "Big" : "Little", spi_mode, + (madctl & MADCTL_BGR) ? LOG_STR_LITERAL("BGR") : LOG_STR_LITERAL("RGB"), display_bits, + is_big_endian ? LOG_STR_LITERAL("Big") : LOG_STR_LITERAL("Little"), spi_mode, static_cast(data_rate / 1000000), bus_width); LOG_PIN(" CS Pin: ", cs); LOG_PIN(" Reset Pin: ", reset); LOG_PIN(" DC Pin: ", dc); - if (offset_width != 0) + if (offset_width != 0) { ESP_LOGCONFIG(TAG, " Offset width: %d", offset_width); - if (offset_height != 0) + } + if (offset_height != 0) { ESP_LOGCONFIG(TAG, " Offset height: %d", offset_height); - if (brightness.has_value()) + } + if (brightness.has_value()) { ESP_LOGCONFIG(TAG, " Brightness: %u", brightness.value()); + } } } // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index b269f46dc98..2552451bd7c 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -246,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); diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index ca9adb4a727..8d2591aefea 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -266,8 +266,6 @@ DriverChip( "JC3636W518V2", height=360, width=360, - offset_height=1, - draw_rounding=1, cs_pin=10, reset_pin=47, invert_colors=True, diff --git a/esphome/components/mipi_spi/models/waveshare.py b/esphome/components/mipi_spi/models/waveshare.py index 0caae5b9396..bdd0c3c90b3 100644 --- a/esphome/components/mipi_spi/models/waveshare.py +++ b/esphome/components/mipi_spi/models/waveshare.py @@ -203,6 +203,54 @@ AXS15231.extend( 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! diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 8291d703462..2d38351898a 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_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index e69de29bb2d..470b7be5fc8 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 00000000000..f9ca3a47e63 --- /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 522b9218fc0..05a29b36654 100644 --- a/esphome/components/mitsubishi_cn105/climate.py +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation import esphome.codegen as cg from esphome.components import climate, uart @@ -7,126 +9,248 @@ from esphome.const import ( CONF_ID, CONF_SUPPORTED_SWING_MODES, CONF_TEMPERATURE, + CONF_UART_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID +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_SUPPORTED_SWING_MODES, default="OFF" - ): validate_climate_swing_mode, + 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_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES])) - 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 4782a2ef93e..3d30d1a25f0 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,8 +1,10 @@ +#include "mitsubishi_cn105.h" + #include #include #include #include -#include "mitsubishi_cn105.h" +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -10,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver"; static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; - static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; static constexpr uint8_t PREAMBLE = 0xFC; @@ -25,91 +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; -template struct LookupMap { - using value_type = decltype(Unknown); - static constexpr auto UNKNOWN_VALUE = Unknown; - const std::array table; - - constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } - - constexpr bool reverse_lookup(value_type value, uint8_t &out) const { - static_assert(N <= std::numeric_limits::max()); - if (value == UNKNOWN_VALUE) { - return false; - } - for (uint8_t i = 0; i < static_cast(N); ++i) { - if (this->table[i] == value) { - out = i; - return true; - } - } - return false; - } - - constexpr bool is_valid(value_type value) const { - uint8_t raw; - return reverse_lookup(value, raw); - } -}; - -template static constexpr auto make_map(const T (&values)[N]) { - return LookupMap{std::to_array(values)}; -} - -static constexpr auto PROTOCOL_MODE_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 constexpr auto PROTOCOL_FAN_MODE_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 constexpr auto PROTOCOL_VANE_MODE_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 constexpr auto PROTOCOL_WIDE_VANE_MODE_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 constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -123,16 +43,19 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } bool MitsubishiCN105::update() { 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; + case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE: if (this->pending_updates_.any()) { this->status_update_wait_credit_ms_ = @@ -185,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; @@ -198,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; @@ -206,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) { @@ -219,7 +145,7 @@ void MitsubishiCN105::did_transition_(State to) { case State::CONNECTED: 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: @@ -227,11 +153,14 @@ void MitsubishiCN105::did_transition_(State to) { break; case State::STATUS_UPDATED: { - 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); } @@ -259,31 +188,33 @@ void MitsubishiCN105::did_transition_(State to) { 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); +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)); } @@ -327,7 +258,7 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) 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; } @@ -335,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); @@ -348,54 +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 = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); - } - - if (!this->pending_updates_.contains(UpdateFlag::VANE)) { - this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); - } - - this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; - if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { - this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); - } - - 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"); @@ -414,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) { @@ -428,101 +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) { - if (!PROTOCOL_MODE_MAP.is_valid(mode)) { - 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) { - if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { - 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)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { - if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { - ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); - return; + 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)); } - this->status_.vane_mode = vane_mode; - this->pending_updates_.set(UpdateFlag::VANE); } void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { - if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { - ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); - return; + 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_.wide_vane_mode = wide_vane_mode; - this->pending_updates_.set(UpdateFlag::WIDE_VANE); } 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) && - PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - if (this->pending_updates_.contains(UpdateFlag::VANE) && - PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { - payload[1] |= 0x10; - } - - if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && - PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { - payload[2] |= 0x01; - if (this->set_wide_vane_high_bit_) { - payload[13] |= 0x80; - } - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, - UpdateFlag::VANE, UpdateFlag::WIDE_VANE); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); @@ -540,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 742d8e18a96..0fee90dfc11 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,9 +1,11 @@ #pragma once +#include "esphome/components/uart/uart.h" +#include "esphome/core/finite_set_mask.h" + #include #include -#include "esphome/components/uart/uart.h" -#include "esphome/core/finite_set_mask.h" +#include namespace esphome::mitsubishi_cn105 { @@ -70,17 +72,18 @@ 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); @@ -98,6 +101,7 @@ class MitsubishiCN105 { CONNECTED, UPDATING_STATUS, STATUS_UPDATED, + DEFERRED_STATUS_REQUEST, SCHEDULE_NEXT_STATUS_UPDATE, WAITING_FOR_SCHEDULED_STATUS_UPDATE, APPLYING_SETTINGS, @@ -120,58 +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, VANE = 4, WIDE_VANE = 5, - REMOTE_TEMPERATURE = 6, + 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_(); - 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 operation_start_ms_{0}; - uint32_t room_temperature_min_interval_ms_{60000}; - std::optional last_room_temperature_update_ms_; + // 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}; - bool set_wide_vane_high_bit_{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 afffe7ea5ec..53b8c21de60 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,15 +74,17 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() { traits.add_supported_fan_mode(p.second); } - traits.set_supported_swing_modes(this->supported_swing_modes_); + traits.set_supported_swing_modes(this->swing_mode_manager_.supported_swing_modes()); - traits.set_visual_min_temperature(16.0f); - traits.set_visual_max_temperature(31.0f); + 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; @@ -100,65 +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 (const auto swing_mode = call.get_swing_mode()) { - auto vane = this->last_non_swing_vane_mode_; - auto wide = this->last_non_swing_wide_vane_mode_; - - switch (*swing_mode) { - case climate::CLIMATE_SWING_BOTH: - vane = MitsubishiCN105::VaneMode::SWING; - wide = MitsubishiCN105::WideVaneMode::SWING; - break; - - case climate::CLIMATE_SWING_VERTICAL: - vane = MitsubishiCN105::VaneMode::SWING; - break; - - case climate::CLIMATE_SWING_HORIZONTAL: - wide = MitsubishiCN105::WideVaneMode::SWING; - break; - - case climate::CLIMATE_SWING_OFF: - default: - break; + if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) { + this->parent_->set_vane_mode(*vane); } - - if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { - this->hp_.set_vane_mode(vane); - } - if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { - this->hp_.set_wide_vane_mode(wide); + if (const auto wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) { + this->parent_->set_wide_vane_mode(*wide); } } - if (this->hp_.is_status_initialized()) { - this->apply_values_(); - } + 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) { @@ -176,64 +144,39 @@ void MitsubishiCN105Climate::apply_values_() { ESP_LOGD(TAG, "Unable to map fan mode"); } - if (!this->supported_swing_modes_.empty()) { - bool vertical_swinging = false; - bool horizontal_swinging = false; - - if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) { - if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) { - vertical_swinging = true; - } else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) { - this->last_non_swing_vane_mode_ = status.vane_mode; - } - } - - if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) { - if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) { - horizontal_swinging = true; - } else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) { - this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode; - } - } - - if (vertical_swinging && horizontal_swinging) { - this->swing_mode = climate::CLIMATE_SWING_BOTH; - } else if (vertical_swinging) { - this->swing_mode = climate::CLIMATE_SWING_VERTICAL; - } else if (horizontal_swinging) { - this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; - } else { - this->swing_mode = climate::CLIMATE_SWING_OFF; - } + 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) { - this->supported_swing_modes_.clear(); + climate::ClimateSwingModeMask supported_swing_modes; switch (mode) { case climate::CLIMATE_SWING_VERTICAL: - this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); - this->supported_swing_modes_.insert(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: - this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); - this->supported_swing_modes_.insert(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: - this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF); - this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL); - this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL); - this->supported_swing_modes_.insert(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 c83a5519c11..cea76278abd 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -1,51 +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_; - 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}; + 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 00000000000..e2a6ee05afd --- /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 00000000000..aa9bfe0d8c5 --- /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 00000000000..1f5faf61af7 --- /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 00000000000..20f54f0bbbb --- /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 00000000000..4ca12edbb49 --- /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 00000000000..d703ddbb025 --- /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 00000000000..656b78b487b --- /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 47164a99976..a3746c019a0 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -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"] @@ -48,7 +51,7 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( ) -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 @@ -70,7 +73,7 @@ def _validate_source_speaker(config): return config -def _validate_output_speaker(config): +def _validate_output_speaker(config: ConfigType) -> ConfigType: audio.final_validate_audio_schema( "mixer", audio_device=CONF_OUTPUT_SPEAKER, @@ -112,7 +115,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) @@ -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/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 6128dc37678..7d33b6c49f8 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_ } size_t bytes_written = 0; std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (temp_ring_buffer.use_count() > 0) { + if (temp_ring_buffer != nullptr) { // Only write to the ring buffer if the reference is valid bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); if (bytes_written > 0) { @@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() { // avoids unnecessary single-frame splices. const size_t ring_buffer_size = (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; - if (this->audio_source_.use_count() == 0) { + if (this->audio_source_ == nullptr) { std::shared_ptr temp_ring_buffer = this->ring_buffer_.lock(); - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); this->ring_buffer_ = temp_ring_buffer; } - if (!temp_ring_buffer) { + if (temp_ring_buffer == nullptr) { return ESP_ERR_NO_MEM; } @@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } bool SourceSpeaker::has_buffered_data() const { - return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data()); + return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); } void SourceSpeaker::set_mute_state(bool mute_state) { @@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - 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_); + this->ducking_ramp_.process(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck); } return bytes_read; @@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; - esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); + this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { @@ -382,8 +382,8 @@ void MixerSpeaker::loop() { ESP_LOGV(TAG, "Stopping"); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); } - if (event_group_bits & MIXER_TASK_STATE_STOPPED) { - this->task_.deallocate(); + // Retries on a subsequent loop if the task is still running on the other core + if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { ESP_LOGD(TAG, "Stopped"); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); this->all_stopped_since_ms_ = 0; @@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) { if (speaker->is_running() && !speaker->get_pause_state()) { // Speaker is running and not paused, so it possibly can provide audio data std::shared_ptr audio_source = speaker->get_audio_source().lock(); - if (audio_source.use_count() == 0) { + if (audio_source == nullptr) { // No audio source allocated, so skip processing this speaker continue; } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 00e89d17826..494443d6951 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,7 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" -#include // esp-audio-libs +#include // esp-audio-libs #include @@ -108,7 +108,7 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool pause_state_{false}; - esp_audio_libs::ducking::DuckingState ducking_state_{}; + esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py new file mode 100644 index 00000000000..d00b4ce8d03 --- /dev/null +++ b/esphome/components/mk2pvrouter/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TAG +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@FredM67"] +DEPENDENCIES = ["uart"] + +mk2pvrouter_ns = cg.esphome_ns.namespace("mk2pvrouter") +Mk2PVRouter = mk2pvrouter_ns.class_("Mk2PVRouter", cg.Component, uart.UARTDevice) + +CONF_MK2PVROUTER_ID = "mk2pvrouter_id" + +# Tags are copied into a fixed-size buffer (MAX_TAG_SIZE = 8 in mk2pvrouter.h), +# which needs room for a trailing null terminator. +MAX_TAG_LEN = 7 + +MK2PVROUTER_LISTENER_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MK2PVROUTER_ID): cv.use_id(Mk2PVRouter), + cv.Required(CONF_TAG): cv.All( + cv.string_strict, cv.Length(min=1, max=MAX_TAG_LEN), lambda x: x.upper() + ), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Mk2PVRouter), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + # Validate UART settings + schema = uart.final_validate_device_schema( + "mk2pvrouter", + baud_rate=9600, + parity="EVEN", + data_bits=7, + stop_bits=1, + require_rx=True, + require_tx=False, + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +_request_listener_slot = cg.slot_counter("MK2PVROUTER_LISTENER_COUNT") + + +async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> None: + """Register a listener with its hub and count it for the compile-time buffer size.""" + _request_listener_slot() + cg.add(mk2pvrouter.register_mk2pvrouter_listener(var)) + + +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/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp new file mode 100644 index 00000000000..0c0476fb11e --- /dev/null +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -0,0 +1,174 @@ +#include "mk2pvrouter.h" +#include "esphome/core/log.h" +#include + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter"; + +constexpr uint8_t START_FRAME = 0x2; +constexpr uint8_t END_FRAME = 0x3; +constexpr uint8_t LINE_FEED = 0xa; +constexpr uint8_t CARRIAGE_RETURN = 0xd; +constexpr uint8_t TAB = 0x9; +constexpr uint8_t MAX_ITERATIONS = 128; +constexpr uint8_t CRC_MASK = 0x3F; +constexpr uint8_t CRC_OFFSET = 0x20; + +// Extracts a TAB-delimited field from [buf_start, buf_end) into dest. +// Returns the field length, or 0 if no TAB was found, or the (uncopied) field +// length if it's >= max_len. +static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); + if (!field_end) + return 0; + const size_t len = field_end - buf_start; + if (len >= max_len) { + ESP_LOGE(TAG, "Field too long: %zu bytes (max %zu)", len, max_len); + return len; + } + + memcpy(dest, buf_start, len); + dest[len] = '\0'; // Null-terminate + return len; +} + +// Calculates the CRC (checksum) for a given group of characters. +uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { + uint8_t crc_tmp{0}; + const auto effective_len = grp_len - CRC_SUFFIX_LEN; + for (size_t i = 0; i < effective_len; i++) { + crc_tmp += grp[i]; + } + crc_tmp &= CRC_MASK; + crc_tmp += CRC_OFFSET; + return crc_tmp; +} + +// Verifies the CRC of a group against its trailing CRC byte. +bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { + const auto grp_len = grp_end - grp; + if (grp_len < static_cast(CRC_SUFFIX_LEN)) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } + const auto raw_crc = grp[grp_len - 1]; + + const auto calculated_crc = this->calculate_crc_(grp, grp_len); + + if (raw_crc != calculated_crc) { + ESP_LOGE(TAG, "CRC mismatch: expected %d, got %d", calculated_crc, raw_crc); + return false; + } + return true; +} + +// Validates, parses, and publishes a single tag/value group. +void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { + if (!this->check_crc_(grp, grp_end)) + return; + + size_t field_len = get_field(this->tag_, grp, grp_end, MAX_TAG_SIZE); + if (!field_len || field_len >= MAX_TAG_SIZE) { + ESP_LOGE(TAG, "Invalid tag"); + return; + } + const auto *val_start = grp + field_len + 1; // Skip tag + TAB. + + field_len = get_field(this->val_, val_start, grp_end, MAX_VAL_SIZE); + if (!field_len || field_len >= MAX_VAL_SIZE) { + ESP_LOGE(TAG, "Invalid value for tag %s", this->tag_); + return; + } + + this->publish_value_(this->tag_, this->val_); +} + +// Reads characters until `c` is found or the internal buffer is full. +bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { + size_t j{0}; + + while (this->available() > 0 && j++ < MAX_ITERATIONS) { + const auto received = this->read(); + if (received < 0) + continue; + if (received == c) + return true; + if (drop) + continue; + if (this->buf_index_ >= (sizeof(this->buf_) - 1)) { + ESP_LOGW(TAG, "Internal buffer full"); + this->buf_index_ = 0; + this->state_ = State::WAITING_FOR_START; + return false; + } + this->buf_[this->buf_index_++] = received; + } + + return false; +} + +void Mk2PVRouter::loop() { + switch (this->state_) { + case State::WAITING_FOR_START: + ESP_LOGVV(TAG, "State: WAITING_FOR_START"); + if (this->read_chars_until_(true, START_FRAME)) + this->state_ = State::START_FRAME_RECEIVED; + break; + case State::START_FRAME_RECEIVED: + ESP_LOGVV(TAG, "State: START_FRAME_RECEIVED"); + if (this->read_chars_until_(false, END_FRAME)) + this->state_ = State::END_FRAME_RECEIVED; + break; + case State::END_FRAME_RECEIVED: { + ESP_LOGVV(TAG, "State: END_FRAME_RECEIVED -> processing"); + + if (this->buf_index_ == 0) { + this->state_ = State::WAITING_FOR_START; + break; + } + + auto *buf_finger = this->buf_; + auto *buf_end = this->buf_ + this->buf_index_; + + // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) + // CRC is computed over "Tag | TAB | Data | TAB". + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + ++buf_finger; // Skip LF to the start of the group. + + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + if (!grp_end) { + ESP_LOGE(TAG, "No group found"); + break; + } + + this->process_group_(buf_finger, grp_end); + + buf_finger = grp_end; // grp_end is always < buf_end, so this stays in bounds. + } + this->buf_index_ = 0; + this->state_ = State::WAITING_FOR_START; + break; + } + } +} + +void Mk2PVRouter::publish_value_(const char *tag, const char *val) { +#ifdef MK2PVROUTER_LISTENER_COUNT + for (auto *element : this->mk2pvrouter_listeners_) { + if (strcmp(tag, element->get_tag()) != 0) + continue; + element->publish_val(val); + } +#endif +} + +void Mk2PVRouter::dump_config() { ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); } + +#ifdef MK2PVROUTER_LISTENER_COUNT +void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) { + this->mk2pvrouter_listeners_.push_back(listener); +} +#endif + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h new file mode 100644 index 00000000000..fc23cf49e80 --- /dev/null +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -0,0 +1,68 @@ +#pragma once + +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::mk2pvrouter { +/* + * Buffer sizes based on the mk2pvrouter telemetry protocol, as implemented by the + * firmware's teleinfo.h (see github.com/FredM67/PVRouter-{1,3}-phase): + * - Tags: max 4 chars (S_MC is longest), most are 1-2 chars (P, V1, R2, etc.) + * - Values: max 6 digits signed (-10000), typical 1-5 digits. Energy (E) is a daily + * counter reset at midnight, so it stays well within 6 digits. + * - Frame: STX + multiple lines (LF+tag+TAB+value+TAB+crc+CR) + ETX + * - Line format: \n\t\t\r (8-15 bytes per line) + * - Multi-phase with all features: ~150-200 bytes + */ +static constexpr uint8_t MAX_TAG_SIZE = 8; // S_MC (4) + digit (1) + null (1) + margin (2) +static constexpr uint8_t MAX_VAL_SIZE = 8; // -10000 (6) + null (1) + margin (1) +static constexpr uint16_t MAX_BUF_SIZE = 256; // Full frame with all features enabled + +// Listener interface for entities that want updates for a specific tag. +class Mk2PVRouterListener { + public: + explicit Mk2PVRouterListener(const char *tag) : tag_(tag) {} + virtual ~Mk2PVRouterListener() = default; + const char *get_tag() const { return this->tag_; } + virtual void publish_val(const char *val) = 0; + + protected: + const char *tag_; +}; + +// Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners. +class Mk2PVRouter final : public Component, public uart::UARTDevice { + public: +#ifdef MK2PVROUTER_LISTENER_COUNT + void register_mk2pvrouter_listener(Mk2PVRouterListener *listener); +#endif + void loop() override; + void dump_config() override; + + protected: + static constexpr size_t CRC_SUFFIX_LEN = 1; + + enum class State : uint8_t { + WAITING_FOR_START, + START_FRAME_RECEIVED, + END_FRAME_RECEIVED, + }; + +#ifdef MK2PVROUTER_LISTENER_COUNT + StaticVector mk2pvrouter_listeners_; +#endif + uint16_t buf_index_{0}; + State state_{State::WAITING_FOR_START}; + char tag_[MAX_TAG_SIZE]; + char val_[MAX_VAL_SIZE]; + char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + + bool read_chars_until_(bool drop, uint8_t c); + uint8_t calculate_crc_(const char *grp, size_t grp_len); + bool check_crc_(const char *grp, const char *grp_end); + void process_group_(const char *grp, const char *grp_end); + void publish_value_(const char *tag, const char *val); +}; +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/sensor/__init__.py b/esphome/components/mk2pvrouter/sensor/__init__.py new file mode 100644 index 00000000000..14fc48a6260 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterSensor = mk2pvrouter_ns.class_( + "Mk2PVRouterSensor", sensor.Sensor, cg.Component +) + +CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp new file mode 100644 index 00000000000..96f1ff5954e --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -0,0 +1,24 @@ +#include "mk2pvrouter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_sensor"; + +Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterSensor::publish_val(const char *val) { + auto result = parse_number(val); + if (!result.has_value()) { + ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag()); + return; + } + this->publish_state(result.value()); +} + +void Mk2PVRouterSensor::dump_config() { + LOG_SENSOR(" ", "Mk2PVRouter Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h new file mode 100644 index 00000000000..e4da41e384b --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component { + public: + explicit Mk2PVRouterSensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index a6330b1cc00..59bdffc1140 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/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2d3b6631bc6..508b3743d18 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F; static const char *const TAG = "mlx90614"; +// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures +static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3; + +// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first +static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); } + void MLX90614Component::setup() { - if (!this->write_emissivity_()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + if (std::isnan(this->emissivity_)) { return; } + this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS; + this->try_write_emissivity_(); + if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } +} + +void MLX90614Component::try_write_emissivity_() { + if (this->emissivity_write_attempts_ == 0) { + return; + } + if (this->write_emissivity_()) { + this->emissivity_write_attempts_ = 0; + return; + } + if (--this->emissivity_write_attempts_ == 0) { + ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS); + this->emissivity_write_failed_ = true; + } } bool MLX90614Component::write_emissivity_() { - if (std::isnan(this->emissivity_)) + // Skip the write when the EEPROM already holds the desired value to save write cycles + uint16_t current_emissivity; + if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) { + return false; + } + + const auto desired_emissivity = static_cast(this->emissivity_ * 0xFFFF); + if (current_emissivity == desired_emissivity) { return true; - uint16_t value = (uint16_t) (this->emissivity_ * 65535); - if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) { - return false; } - delay(10); - if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) { - return false; - } - delay(10); - return true; + + return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity); } -bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) { +bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) { + // The PEC covers the whole write transaction: SLA+W, command, data low, data high uint8_t buf[5]; buf[0] = this->address_ << 1; buf[1] = reg; - buf[2] = data & 0xFF; - buf[3] = data >> 8; - buf[4] = crc8(buf, 4, 0x00, 0x07, true); - return this->write_bytes(reg, buf + 2, 3); + + // See datasheet 8.3.3.1 EEPROM write sequence + // 1. Write 0x0000 into the cell of interest (erases the cell) + buf[2] = buf[3] = 0; + buf[4] = crc8_pec(buf, 4); + auto ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec); + return false; + } + + // 2. Wait at least 5ms + delay(10); + + // 3. Write the new value + if (data != 0) { + buf[2] = data & 0xFF; + buf[3] = data >> 8; + buf[4] = crc8_pec(buf, 4); + ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec); + return false; + } + // 4. Wait at least 5ms + delay(10); + } + + // 5. Read back to confirm the value was stored + uint16_t read_back; + ec = this->read_register_(reg, read_back); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec); + return false; + } + + if (read_back != data) { + ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back); + return false; + } + + return true; +} + +i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { + // The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high + uint8_t buf[6]; + buf[0] = this->address_ << 1; + buf[1] = reg; + buf[2] = (this->address_ << 1) | 0x01; + + const auto ec = this->read_register(reg, buf + 3, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "i2c read error %d", ec); + return ec; + } + + const auto expected_pec = crc8_pec(buf, 5); + if (buf[5] != expected_pec) { + ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]); + return i2c::ERROR_CRC; + } + + data = encode_uint16(buf[4], buf[3]); + return i2c::ERROR_OK; } void MLX90614Component::dump_config() { ESP_LOGCONFIG(TAG, "MLX90614:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->emissivity_write_attempts_ != 0) { + ESP_LOGW(TAG, " Emissivity not written yet, will retry"); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Ambient", this->ambient_sensor_); @@ -71,33 +156,41 @@ void MLX90614Component::dump_config() { } void MLX90614Component::update() { - uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; + // Temperature reads run regardless of the emissivity state so a failure still shows up as NAN + this->try_write_emissivity_(); + + // Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale + auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) { + if (sensor == nullptr) { + return i2c::ERROR_OK; + } + + uint16_t raw; + const auto ec = this->read_register_(reg, raw); + if (ec != i2c::ERROR_OK) { + sensor->publish_state(NAN); + return ec; + } + + // Bit 15 set means the device flagged the reading as invalid + const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f; + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature); + sensor->publish_state(temperature); + return ec; + }; + + const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1); + const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT); + + if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read some sensors")); + } else if (this->emissivity_write_failed_) { + this->status_set_warning(LOG_STR("Failed to write emissivity")); + } else if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } else { + this->status_clear_warning(); } - uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f; - float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f; - - ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient); - - if (this->ambient_sensor_ != nullptr && !std::isnan(ambient)) - this->ambient_sensor_->publish_state(ambient); - if (this->object_sensor_ != nullptr && !std::isnan(object)) - this->object_sensor_->publish_state(object); - this->status_clear_warning(); } } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 882ee45186a..758792acede 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { void set_emissivity(float emissivity) { emissivity_ = emissivity; } protected: + void try_write_emissivity_(); bool write_emissivity_(); - bool write_bytes_(uint8_t reg, uint16_t data); + bool write_register_(uint8_t reg, uint16_t data); + i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data); sensor::Sensor *ambient_sensor_{nullptr}; sensor::Sensor *object_sensor_{nullptr}; float emissivity_{NAN}; + // Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear + uint8_t emissivity_write_attempts_{0}; + bool emissivity_write_failed_{false}; }; } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index 6a34c4bdc00..0cf9b95dde8 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/sensor.py b/esphome/components/mmc5603/sensor.py index 6d2bafdd0e6..a9f240508c1 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/sensor.py b/esphome/components/mmc5983/sensor.py index aaff2946f24..797181690f4 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 9e64540382f..76cfdbed706 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -1,19 +1,42 @@ from __future__ import annotations import logging -from typing import Literal +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) @@ -22,6 +45,7 @@ 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" @@ -31,6 +55,104 @@ CONF_TURNAROUND_TIME = "turnaround_time" 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": [], +} + + +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 (the runtime hub never queues one: + queue_pdu() refuses them). 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 + ), + ) + + +async def register_templatable_command_options( + var: MockObj, config: ConfigType, args: TemplateArgsType, direction: str +) -> None: + """Generate the set_