Merge branch 'dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-08-12 17:27:35 +12:00
committed by GitHub
984 changed files with 50402 additions and 7012 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ contact_links:
url: https://github.com/esphome/esphome-webserver/issues/new/choose url: https://github.com/esphome/esphome-webserver/issues/new/choose
about: Report an issue with the ESPHome web server. about: Report an issue with the ESPHome web server.
- name: Report an issue with the ESPHome Builder / Dashboard - 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. about: Report an issue with the ESPHome Builder / Dashboard.
- name: Report an issue with the ESPHome API client - name: Report an issue with the ESPHome API client
url: https://github.com/esphome/aioesphomeapi/issues/new/choose url: https://github.com/esphome/aioesphomeapi/issues/new/choose
@@ -13,6 +13,7 @@ module.exports = {
'merging-to-release', 'merging-to-release',
'merging-to-beta', 'merging-to-beta',
'chained-pr', 'chained-pr',
'stacked-pr',
'core', 'core',
'small-pr', 'small-pr',
'medium-pr', 'medium-pr',
+38 -1
View File
@@ -33,8 +33,41 @@ 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 // Strategy: Merge branch detection
async function detectMergeBranch(context) { async function detectMergeBranch(github, context) {
const labels = new Set(); const labels = new Set();
const baseRef = context.payload.pull_request.base.ref; const baseRef = context.payload.pull_request.base.ref;
@@ -42,7 +75,11 @@ async function detectMergeBranch(context) {
labels.add('merging-to-release'); labels.add('merging-to-release');
} else if (baseRef === 'beta') { } else if (baseRef === 'beta') {
labels.add('merging-to-beta'); labels.add('merging-to-beta');
} 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 !== 'dev') { } else if (baseRef !== 'dev') {
// A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr'); labels.add('chained-pr');
} }
+2 -2
View File
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
// Early exit for release and beta branches only // Early exit for release and beta branches only
if (baseRef === 'release' || baseRef === 'beta') { if (baseRef === 'release' || baseRef === 'beta') {
const branchLabels = await detectMergeBranch(context); const branchLabels = await detectMergeBranch(github, context);
const finalLabels = Array.from(branchLabels); const finalLabels = Array.from(branchLabels);
console.log('Computed labels (merge branch only):', finalLabels.join(', ')); console.log('Computed labels (merge branch only):', finalLabels.join(', '));
@@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => {
deprecatedResult, deprecatedResult,
maintainerAccess maintainerAccess
] = await Promise.all([ ] = await Promise.all([
detectMergeBranch(context), detectMergeBranch(github, context),
detectComponentPlatforms(changedFiles, apiData), detectComponentPlatforms(changedFiles, apiData),
detectNewComponents(github, context, prFiles), detectNewComponents(github, context, prFiles),
detectNewPlatforms(github, context, prFiles, apiData), detectNewPlatforms(github, context, prFiles, apiData),
@@ -1,6 +1,7 @@
const { describe, it } = require('node:test'); const { describe, it } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { const {
detectMergeBranch,
detectNewPlatforms, detectNewPlatforms,
detectNewComponents, detectNewComponents,
detectPRSize, detectPRSize,
@@ -36,6 +37,107 @@ const API_DATA = {
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})'; const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]'; 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 } = {}) {
const pull_request = { number: 1, base: { ref: baseRef } };
if (stack !== undefined) {
pull_request.stack = stack;
}
return {
repo: { owner: 'esphome', repo: 'esphome' },
payload: { pull_request }
};
}
// 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);
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// detectNewPlatforms // detectNewPlatforms
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
# Scope the minted App token to the minimum needed by auto-label-pr/*.js. # 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-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment 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 - name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+2 -2
View File
@@ -182,8 +182,8 @@ jobs:
contents: read # actions/checkout to load the test configs contents: read # actions/checkout to load the test configs
strategy: strategy:
fail-fast: false fail-fast: false
# Cap concurrency so this smoke test doesn't hog all the shared runners. # Modest cap so this smoke test leaves room on the shared runner pool.
max-parallel: 2 max-parallel: 8
matrix: matrix:
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
# share a toolchain bundle, so esp32 is exercised on the base variant # share a toolchain bundle, so esp32 is exercised on the base variant
+263 -270
View File
@@ -65,203 +65,9 @@ jobs:
python -m venv venv python -m venv venv
. venv/bin/activate . venv/bin/activate
python --version 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 . uv pip install -e .
pylint:
name: Check pylint
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.python-linters == '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: Run pylint
run: |
. venv/bin/activate
pylint -f parseable --persistent=n esphome
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
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_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
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
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
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
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
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
determine-jobs: determine-jobs:
name: Determine which jobs to run name: Determine which jobs to run
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -351,6 +157,169 @@ jobs:
path: .temp/components_graph.json path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} 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_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
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.python-linters == '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: Run pylint
run: |
. venv/bin/activate
pylint -f parseable --persistent=n esphome
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
lint-format:
name: Check lint and formatting
runs-on: ubuntu-latest
needs:
- 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'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run prek
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
with:
# Keep in sync with requirements_test.txt.
prek-version: "0.4.11"
# This job only runs on pull requests, so nothing ever populates
# the cache on dev. Every run would miss and then write a per-pull
# request copy, which is what the old seed-cache job existed to
# avoid. Building the hooks from scratch takes seconds, so skip it.
cache: false
env:
PREK_SKIP: pylint,ci-custom
# Pushes any fixes the hooks made back to the pull request. This step
# must keep its default name: the GitHub App that performs the push
# locates the workflow run by that name.
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
with:
msg: apply automatic formatting fixes
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
codecov-empty-upload:
name: Report no coverage to Codecov
runs-on: ubuntu-24.04
needs:
- determine-jobs
# ``pytest`` is the only job that uploads coverage, and it is skipped when
# every changed file is CI-irrelevant (see ``should_run_core_ci`` in
# ``script/determine-jobs.py``). With no upload Codecov never reports a
# result, so the required ``codecov/patch`` status stays pending forever and
# the pull request can never be merged. Tell Codecov up front that this
# commit has nothing to cover so it publishes a passing status instead.
#
# ``force`` skips Codecov's own check that every changed file is ignorable;
# ``determine-jobs`` has already decided none of these files can affect
# coverage, and Codecov would otherwise fail the status for paths it does
# not recognise as non-testable (``docker/**``, ``.yamllint``).
if: needs.determine-jobs.outputs.core-ci == 'false'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Report empty upload to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
run_command: empty-upload
force: true
fail_ci_if_error: true
integration-tests: integration-tests:
name: Run integration tests (${{ matrix.bucket.name }}) name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -440,32 +409,33 @@ jobs:
path: ~/.cache/esphome/platformio-ccache path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }} key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
cpp-unit-tests: import-time:
name: Run C++ unit tests name: Check import esphome.__main__ time
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- common - common
- determine-jobs - determine-jobs
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') if: needs.determine-jobs.outputs.import-time == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Check import time against budget and write waterfall HAR
- name: Run cpp_unit_test.py
run: | run: |
. venv/bin/activate . venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then script/check_import_time.py --check --har importtime.har
script/cpp_unit_test.py --all - name: Upload waterfall HAR
else if: always()
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
script/cpp_unit_test.py $ARGS with:
fi name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
benchmarks: benchmarks:
name: Run CodSpeed benchmarks name: Run CodSpeed benchmarks
@@ -496,7 +466,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks - name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1 uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
with: with:
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -504,6 +474,33 @@ jobs:
pytest tests/benchmarks/python/ --codspeed --no-cov pytest tests/benchmarks/python/ --codspeed --no-cov
mode: simulation mode: simulation
cpp-unit-tests:
name: Run C++ unit tests
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
uses: actions/checkout@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: Run cpp_unit_test.py
run: |
. venv/bin/activate
if [ "${{ needs.determine-jobs.outputs.cpp-unit-tests-run-all }}" = "true" ]; then
script/cpp_unit_test.py --all
else
ARGS=$(echo '${{ needs.determine-jobs.outputs.cpp-unit-tests-components }}' | jq -r '.[] | @sh' | xargs)
script/cpp_unit_test.py $ARGS
fi
clang-tidy-single: clang-tidy-single:
name: ${{ matrix.name }} name: ${{ matrix.name }}
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -519,7 +516,6 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 2
matrix: matrix:
include: include:
- id: clang-tidy - id: clang-tidy
@@ -710,7 +706,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 3
matrix: matrix:
include: include:
- id: clang-tidy - id: clang-tidy
@@ -790,7 +785,6 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 3
matrix: matrix:
include: include:
- id: clang-tidy - id: clang-tidy
@@ -877,7 +871,7 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
matrix: matrix:
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps: steps:
@@ -1068,69 +1062,62 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built): # 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 python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
pre-commit-seed-cache: device-builder:
name: Seed pre-commit cache name: Test downstream esphome/device-builder
runs-on: ubuntu-latest runs-on: ubuntu-24.04
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
needs: needs:
- common - common
- determine-jobs - 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: steps:
- name: Check out code from GitHub - name: Check out esphome (this PR)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} path: esphome
cache-key: ${{ needs.common.outputs.cache-key }} - name: Check out esphome/device-builder
# Inlined from esphome/pre-commit-action with a restore-only cache uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with: with:
path: ~/.cache/pre-commit repository: esphome/device-builder
# Must match the key pre-commit-seed-cache saves ref: main
# yamllint disable-line rule:line-length path: device-builder
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - name: Set up Python
- name: Run pre-commit uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
env: with:
SKIP: pylint,ci-custom 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: | run: |
python -m pip install pre-commit uv pip install --system -e './device-builder[esphome,test]'
pre-commit run --show-diff-on-failure --color=always --all-files uv pip install --system -e ./esphome
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - name: Run device-builder pytest
if: always() # ``-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: memory-impact-target-branch:
name: Build target branch for memory impact name: Build target branch for memory impact
@@ -1431,21 +1418,27 @@ jobs:
ci-status: ci-status:
name: CI Status name: CI Status
runs-on: ubuntu-24.04 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: needs:
- common - common
- determine-jobs
- ci-custom - ci-custom
- pylint - pylint
- lint-format
- pytest - pytest
- codecov-empty-upload
- integration-tests - integration-tests
- import-time
- cpp-unit-tests
- clang-tidy-single - clang-tidy-single
- clang-tidy-nosplit - clang-tidy-nosplit
- clang-tidy-split - clang-tidy-split
- clang-tidy-esp32-variants - clang-tidy-esp32-variants
- determine-jobs
- device-builder
- test-build-components-split - test-build-components-split
- test-esp32-platformio - test-esp32-platformio
- pre-commit-ci-lite - device-builder
- memory-impact-target-branch - memory-impact-target-branch
- memory-impact-pr-branch - memory-impact-pr-branch
- memory-impact-comment - memory-impact-comment
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1 exit 1
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"
+38
View File
@@ -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}"
+30 -6
View File
@@ -1,12 +1,23 @@
--- ---
name: Publish Release 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: on:
workflow_dispatch: 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: release:
types: [published] types: [published]
schedule:
- cron: "0 2 * * *"
permissions: permissions:
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write 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 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get tag - name: Get tag
id: tag id: tag
env:
INPUT_TAG: ${{ github.event.inputs.tag }}
# yamllint disable rule:line-length # yamllint disable rule:line-length
run: | run: |
if [[ "${{ github.event_name }}" = "release" ]]; then if [[ "${{ github.event_name }}" = "release" ]]; then
@@ -34,12 +47,23 @@ jobs:
ENVIRONMENT="production" ENVIRONMENT="production"
fi fi
else 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/} 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 if [[ "$BRANCH" != "dev" ]]; then
TAG="${TAG}-${BRANCH}"
BRANCH_BUILD="true" BRANCH_BUILD="true"
ENVIRONMENT="" ENVIRONMENT=""
else else
+35 -50
View File
@@ -6,61 +6,46 @@ on:
- cron: "30 0 * * *" - cron: "30 0 * * *"
workflow_dispatch: workflow_dispatch:
permissions: # The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
issues: write # actions/stale labels, comments on, and closes stale issues # needs no permissions at all.
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests permissions: {}
concurrency:
group: lock
jobs: jobs:
stale: stale:
if: github.repository_owner == 'esphome' if: github.repository_owner == 'esphome'
runs-on: ubuntu-latest # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
steps: # GitHub App token so the labels, comments and closures come from
- name: Stale # esphome[bot] instead of github-actions[bot].
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 uses: esphome/workflows/.github/workflows/stale.yml@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
with: secrets:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
remove-stale-when-updated: true with:
operations-per-run: 400 # 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 If you are the author of this PR, please leave a comment if you want
# - PRs to keep it open. Also, please rebase your PR onto the latest dev
# - No PRs marked as "not-stale" branch to ensure that it's up to date with the latest changes.
# - 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 Thank you for your contribution!
to keep it open. Also, please rebase your PR onto the latest dev stale-issue-message: >
branch to ensure that it's up to date with the latest changes. 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 This issue has now been marked as stale and will be closed if no
# - Issues further activity occurs. Thank you for your contributions.
# - 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.
+11 -11
View File
@@ -44,7 +44,7 @@ jobs:
- name: Set up uv - name: Set up uv
# An order of magnitude faster than pip on cold boots, with its # An order of magnitude faster than pip on cold boots, with its
# own wheel cache. ``--system`` (below) installs into the # 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 # ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix. # ``uv run`` prefix.
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
@@ -58,19 +58,19 @@ jobs:
- name: Install Home Assistant - name: Install Home Assistant
run: | run: |
uv pip install --system -e lib/home-assistant 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 - name: Sync
run: | run: |
python ./script/sync-device_class.py 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 # 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 # which would otherwise abort the workflow before the auto-fixes
# can flow into the sync PR. # can flow into the sync PR.
# #
# SKIP: # PREK_SKIP:
# - no-commit-to-branch is a local guard against committing on # - no-commit-to-branch is a local guard against committing on
# dev/release/beta; CI runs on dev by definition, and # dev/release/beta; CI runs on dev by definition, and
# peter-evans/create-pull-request creates the branch itself. # peter-evans/create-pull-request creates the branch itself.
@@ -79,18 +79,18 @@ jobs:
# the runtime deps (HA + requirements*.txt); main CI already # the runtime deps (HA + requirements*.txt); main CI already
# gates pylint on real PRs. # gates pylint on real PRs.
env: env:
SKIP: pylint,no-commit-to-branch PREK_SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files || true 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. # Second pass: re-run all hooks against the now-fixed tree.
# Auto-fixers exit 0 (nothing to change); any remaining failure # Auto-fixers exit 0 (nothing to change); any remaining failure
# from a check-only hook (flake8 / yamllint / ci-custom) is a # 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. # above for the same reasons.
env: env:
SKIP: pylint,no-commit-to-branch PREK_SKIP: pylint,no-commit-to-branch
run: python script/run-in-env.py pre-commit run --all-files run: python script/run-in-env.py prek run --all-files
- name: Commit changes - name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
+2
View File
@@ -133,6 +133,8 @@ CTestTestfile.cmake
.gcc-flags.json .gcc-flags.json
config/ config/
# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
!tests/component_tests/**/config/
tests/build/ tests/build/
tests/.esphome/ tests/.esphome/
/.temp-clang-tidy.cpp /.temp-clang-tidy.cpp
+9 -3
View File
@@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro
- Function-local constants: `lower_snake_case` - Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations - 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:** * **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: * **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:
@@ -412,7 +418,7 @@ This document provides essential context for AI models interacting with this pro
* **Configuration:** * **Configuration:**
* `pyproject.toml`: Defines the Python project metadata and dependencies. * `pyproject.toml`: Defines the Python project metadata and dependencies.
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers. * `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`. * **CI/CD Pipeline:** Defined in `.github/workflows`.
* **Static Analysis & Development:** * **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. * `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.
@@ -420,7 +426,7 @@ This document provides essential context for AI models interacting with this pro
## 6. Development & Testing Workflow ## 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`. * **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:** * **Testing:**
* **Python:** Run unit tests with `pytest`. * **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis. * **C++:** Use `clang-tidy` for static analysis.
@@ -493,7 +499,7 @@ 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 <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch). 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
2. **Make Changes:** Adhere to all coding conventions and patterns. 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. 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. 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 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. 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.
+9
View File
@@ -78,6 +78,7 @@ esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_device_base/* @Bl00d-B0b esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_nus/* @tomaszduda23 esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_connection/* @bdraco @jesserockz
esphome/components/bluetooth_proxy/* @bdraco @jesserockz esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis esphome/components/bm8563/* @abmantis
esphome/components/bme280_base/* @esphome/core esphome/components/bme280_base/* @esphome/core
@@ -237,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
esphome/components/hm3301/* @freekode esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2
esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/switch/* @Links2004 esphome/components/homeassistant/switch/* @Links2004
@@ -288,6 +290,7 @@ esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu esphome/components/ld2450/* @hareeshmu
esphome/components/ld24xx/* @kbx81 esphome/components/ld24xx/* @kbx81
esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter esphome/components/ledc/* @OttoWinter
esphome/components/libretiny/* @kuba2k2 esphome/components/libretiny/* @kuba2k2
esphome/components/libretiny_pwm/* @kuba2k2 esphome/components/libretiny_pwm/* @kuba2k2
@@ -295,6 +298,8 @@ esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246 esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lm75b/* @beormund esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core esphome/components/logger/* @esphome/core
@@ -349,6 +354,7 @@ esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz esphome/components/mlx90614/* @jesserockz
esphome/components/mmc5603/* @benhoff esphome/components/mmc5603/* @benhoff
esphome/components/mmc5983/* @agoode esphome/components/mmc5983/* @agoode
esphome/components/modbus_client/* @exciton
esphome/components/modbus_controller/* @martgras esphome/components/modbus_controller/* @martgras
esphome/components/modbus_controller/binary_sensor/* @martgras esphome/components/modbus_controller/binary_sensor/* @martgras
esphome/components/modbus_controller/number/* @martgras esphome/components/modbus_controller/number/* @martgras
@@ -437,6 +443,7 @@ esphome/components/rp2/* @jesserockz
esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz esphome/components/rp2040_pwm/* @jesserockz
esphome/components/rp2_ble_tracker/* @bdraco
esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet @ximex esphome/components/rtttl/* @glmnet @ximex
@@ -459,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sen6x/* @martgras @mebner86 @tuct
esphome/components/sendspin/* @kahrendt esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt
@@ -627,6 +635,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
esphome/components/xxtea/* @clydebarrow esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23 esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23 esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zephyr_pwm/* @wiomoc
esphome/components/zhlt01/* @cfeenstra1024 esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @luar123 @tomaszduda23 esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt esphome/components/zio_ultrasonic/* @kahrendt
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt -r /requirements.txt
# Install the ESPHome Device Builder dashboard. # Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.7.0 RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5
RUN \ RUN \
platformio settings set enable_telemetry No \ platformio settings set enable_telemetry No \
+95 -53
View File
@@ -2,16 +2,12 @@
import argparse import argparse
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from datetime import datetime
import functools import functools
import getpass
import importlib import importlib
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import re import re
import shutil
import subprocess
import sys import sys
import time import time
from typing import Protocol from typing import Protocol
@@ -19,16 +15,18 @@ from typing import Protocol
# Note: Do not import modules from esphome.components here, as this would # Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting # cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one. # 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 ( from esphome.const import (
ALLOWED_NAME_CHARS, ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE, ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API, CONF_API,
CONF_AUTH, CONF_AUTH,
CONF_BAUD_RATE, CONF_BAUD_RATE,
CONF_BROKER, CONF_BROKER,
CONF_DEASSERT_RTS_DTR, CONF_DEASSERT_RTS_DTR,
CONF_DISABLED, CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ESPHOME, CONF_ESPHOME,
CONF_LEVEL, CONF_LEVEL,
CONF_LOG_TOPIC, CONF_LOG_TOPIC,
@@ -48,6 +46,8 @@ from esphome.const import (
CONF_WEB_SERVER, CONF_WEB_SERVER,
CONF_WIFI, CONF_WIFI,
ENV_NOGITIGNORE, ENV_NOGITIGNORE,
KEY_ESP32,
KEY_VARIANT,
SECRETS_FILES, SECRETS_FILES,
Toolchain, Toolchain,
) )
@@ -55,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log from esphome.log import AnsiFore, color, setup_log
from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type from esphome.upload_targets import PortType, get_port_type
from esphome.util import ( from esphome.util import (
@@ -484,8 +485,6 @@ def has_web_server_ota() -> bool:
def has_mqtt_ip_lookup() -> bool: def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported.""" """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: if CONF_MQTT not in CORE.config:
return False return False
# Default Enabled # Default Enabled
@@ -618,6 +617,8 @@ def _resolve_network_devices(
def run_miniterm(config: ConfigType, port: str, args) -> int: def run_miniterm(config: ConfigType, port: str, args) -> int:
from datetime import datetime
from aioesphomeapi import LogParser from aioesphomeapi import LogParser
import serial import serial
@@ -630,18 +631,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
return 1 return 1
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate) _LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
process_stacktrace = None # Decoder resolution, crash isolation, and disable-after-failure
# all live in LogLineProcessor, shared with the API log path.
try: processor = LogLineProcessor(config, CORE.target_platform)
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
ser = serial.Serial() ser = serial.Serial()
ser.baudrate = baud_rate ser.baudrate = baud_rate
ser.port = port ser.port = port
@@ -681,11 +673,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
"utf8", "backslashreplace" "utf8", "backslashreplace"
) )
safe_print(parser.parse_line(line, time_str)) safe_print(parser.parse_line(line, time_str))
processor.process_line(line)
if process_stacktrace is not None:
backtrace_state = process_stacktrace(
config, line, backtrace_state
)
except serial.SerialException: except serial.SerialException:
_LOGGER.error("Serial port closed!") _LOGGER.error("Serial port closed!")
return 0 return 0
@@ -930,9 +918,10 @@ def upload_using_esptool(
mcu = "esp8266" mcu = "esp8266"
if CORE.is_esp32: if CORE.is_esp32:
from esphome.components.esp32 import get_esp32_variant # Same lookup as esp32.get_esp32_variant(), read directly so the
# serial upload path does not import the esp32 package; both the
mcu = get_esp32_variant().lower() # 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]] = [] line_callbacks: list[Callable[[str], str | None]] = []
if ( if (
@@ -986,6 +975,8 @@ def upload_using_esptool(
def upload_using_platformio(config: ConfigType, port: str) -> int: def upload_using_platformio(config: ConfigType, port: str) -> int:
import shutil
from esphome.platformio import toolchain from esphome.platformio import toolchain
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
@@ -1023,6 +1014,8 @@ def upload_using_picotool(config: ConfigType) -> int:
the mass storage copy approach that causes "disk not ejected properly" the mass storage copy approach that causes "disk not ejected properly"
warnings on macOS. warnings on macOS.
""" """
import subprocess
from esphome.platformio import toolchain from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config) idedata = toolchain.get_idedata(config)
@@ -1129,6 +1122,8 @@ def check_permissions(port: str):
"the USB cable can be used for data and is not a power-only cable." "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)): if not (os.access(port, os.R_OK | os.W_OK)):
import getpass
raise EsphomeError( raise EsphomeError(
"You do not have read or write permission on the selected serial port. " "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 " "To resolve this issue, you can add your user to the dialout group "
@@ -1141,12 +1136,11 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str] config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]: ) -> tuple[int, str | None]:
host = devices[0] host = devices[0]
try: platform_upload = platform_hooks.get_platform_hook(
module = importlib.import_module("esphome.components." + CORE.target_platform) CORE.target_platform, "upload_program"
if module.upload_program(config, args, host): )
return 0, host if platform_upload is not None and platform_upload(config, args, host):
except AttributeError: return 0, host
pass
port_type = get_port_type(host) port_type = get_port_type(host)
@@ -1406,12 +1400,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool:
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None: def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
try: platform_show_logs = platform_hooks.get_platform_hook(
module = importlib.import_module("esphome.components." + CORE.target_platform) CORE.target_platform, "show_logs"
if module.show_logs(config, args, devices): )
return 0 if platform_show_logs is not None and platform_show_logs(config, args, devices):
except AttributeError: return 0
pass
if "logger" not in config: if "logger" not in config:
raise EsphomeError("Logger is not configured!") raise EsphomeError("Logger is not configured!")
@@ -1429,7 +1422,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
if has_api() and ( if has_api() and (
network_devices := _resolve_network_devices(devices, config, args) network_devices := _resolve_network_devices(devices, config, args)
): ):
from esphome.components.api.client import run_logs from esphome.api_client import run_logs
return run_logs( return run_logs(
config, config,
@@ -1713,7 +1706,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_bundle(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) creator = ConfigBundleCreator(config)
@@ -1948,7 +1941,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_name = args.name new_name = args.name
for c in new_name: for c in new_name:
if c not in ALLOWED_NAME_CHARS: if c not in ALLOWED_NAME_CHARS:
print( safe_print(
color( color(
AnsiFore.BOLD_RED, AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: " f"'{c}' is an invalid character for names. Valid characters are: "
@@ -1961,7 +1954,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
yaml = yaml_util.load_yaml(CORE.config_path) yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]: if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
print( safe_print(
color( color(
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed." AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
) )
@@ -2008,7 +2001,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
) )
> 1 > 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 return 1
new_raw = re.sub( new_raw = re.sub(
@@ -2026,7 +2021,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen`` # ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname). # would otherwise just re-flash the same hostname).
if new_name == old_name: if new_name == old_name:
print( safe_print(
color( color(
AnsiFore.BOLD_RED, AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.", f"'{new_name}' is already the device's name.",
@@ -2036,7 +2031,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_path: Path = CORE.config_dir / (new_name + ".yaml") new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve(): if new_path.resolve() == CORE.config_path.resolve():
print( safe_print(
color( color(
AnsiFore.BOLD_RED, AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.", f"'{new_name}' is already the device's name.",
@@ -2044,7 +2039,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
) )
return 1 return 1
if new_path.exists(): if new_path.exists():
print( safe_print(
color( color(
AnsiFore.BOLD_RED, AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. " f"Cannot rename: {new_path} already exists. "
@@ -2052,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
) )
) )
return 1 return 1
print( safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}" f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
) )
print() print()
@@ -2061,7 +2056,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
if rc != 0: 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() new_path.unlink()
return 1 return 1
@@ -2087,7 +2082,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
if CORE.config_path != new_path: if CORE.config_path != new_path:
CORE.config_path.unlink() CORE.config_path.unlink()
print(color(AnsiFore.BOLD_GREEN, "SUCCESS")) safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print() print()
return 0 return 0
@@ -2514,6 +2509,49 @@ def parse_args(argv):
return parser.parse_args(arguments) 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): def run_esphome(argv):
from esphome.address_cache import AddressCache from esphome.address_cache import AddressCache
@@ -2532,6 +2570,7 @@ def run_esphome(argv):
args.log_level = "CRITICAL" args.log_level = "CRITICAL"
setup_log(log_level=args.log_level) setup_log(log_level=args.log_level)
_warn_if_source_tree_mismatch()
if args.command in PRE_CONFIG_ACTIONS: if args.command in PRE_CONFIG_ACTIONS:
try: try:
@@ -2563,10 +2602,11 @@ def run_esphome(argv):
return 0 return 0
# Bundle support: if the configuration is a .esphomebundle, extract it # Bundle support: if the configuration is a .esphomebundle, extract it
# and rewrite conf_path to the extracted YAML config. # and rewrite conf_path to the extracted YAML config. The suffix check
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile # 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) _LOGGER.info("Extracting config bundle %s...", conf_path)
conf_path = prepare_bundle_for_compile(conf_path) conf_path = prepare_bundle_for_compile(conf_path)
# Update the argument so downstream code sees the extracted path # Update the argument so downstream code sees the extracted path
@@ -2607,6 +2647,8 @@ def run_esphome(argv):
config = read_config( config = read_config(
command_line_substitutions, command_line_substitutions,
skip_external_update=skip_external, 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 # Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage # instead of re-running read_config. Skip when the storage
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
import logging
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 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,
) -> None:
"""Run the logs command in the event loop."""
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)
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 suppress(KeyboardInterrupt):
asyncio.run(
async_run_logs(config, addresses, subscribe_states=subscribe_states)
)
+111 -18
View File
@@ -11,43 +11,136 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from itertools import count
import logging
import threading 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): class AsyncThreadRunner[T](threading.Thread):
"""Run an async coroutine in a daemon thread and expose its result. """Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in ``event`` is always set, even when the coroutine crashes, so waiters
``exception`` so ``event`` is always set — this prevents callers waiting never hang; ``completed`` distinguishes a delivered result (even a
on ``event`` from hanging forever when the coroutine crashes. legitimate ``None``) from a coroutine that never finished. Prefer
:func:`run_async`; use this class directly only when a failure should
Typical usage:: degrade to a default value instead of raising.
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
""" """
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: 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._coro_factory = coro_factory
self.result: T | None = None self.result: T | None = None
self.exception: BaseException | None = None self.exception: BaseException | None = None
self.completed = False
self.event = threading.Event() self.event = threading.Event()
async def _runner(self) -> None: async def _runner(self) -> None:
try: try:
self.result = await self._coro_factory() self.result = await self._coro_factory()
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except # Distinguishes a delivered result from "never ran", since None
# Capture all exceptions so ``event`` is always set — otherwise a # is a valid result value.
# crash would hang the waiter forever. 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 self.exception = exc
finally: finally:
self.event.set() self.event.set()
def run(self) -> None: 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)
+37 -6
View File
@@ -20,6 +20,7 @@ from typing import Any
from esphome import const, yaml_util from esphome import const, yaml_util
from esphome.const import ( from esphome.const import (
BUNDLE_EXTENSION,
CONF_ESPHOME, CONF_ESPHOME,
CONF_EXTERNAL_COMPONENTS, CONF_EXTERNAL_COMPONENTS,
CONF_INCLUDES, CONF_INCLUDES,
@@ -29,12 +30,12 @@ from esphome.const import (
CONF_TYPE, CONF_TYPE,
) )
from esphome.core import CORE, EsphomeError from esphome.core import CORE, EsphomeError
from esphome.util import filter_yaml_files
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bundle" DOMAIN = "bundle"
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json" MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1 CURRENT_MANIFEST_VERSION = 1
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
@@ -128,6 +129,9 @@ class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data.""" """Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list) 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, # Original config dir parsed from an extracted bundle's manifest.json,
# kept in the path flavor of the machine the bundle was created on. # 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; # The checked flag makes the manifest lookup happen at most once per run;
@@ -155,6 +159,30 @@ def add_bundle_file(path: Path) -> None:
_get_data().extra_files.append(CORE.relative_config_path(path)) _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 # 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 # paths do neither in practice, so this is how the flavor of a recorded
# path string is recognized on any host. # path string is recognized on any host.
@@ -310,6 +338,7 @@ class ConfigBundleCreator:
yaml_sources = [ yaml_sources = [
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml") 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) used_secret_keys = _find_used_secret_keys(yaml_sources)
filtered_secrets = self._build_filtered_secrets(used_secret_keys) filtered_secrets = self._build_filtered_secrets(used_secret_keys)
@@ -394,6 +423,13 @@ class ConfigBundleCreator:
""" """
discovered = yaml_util.discover_user_yaml_files(self._config_path) discovered = yaml_util.discover_user_yaml_files(self._config_path)
self._secrets_paths.update(discovered.secrets) 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() config_resolved = self._config_path.resolve()
for fpath in discovered.files: for fpath in discovered.files:
if fpath == config_resolved: if fpath == config_resolved:
@@ -719,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
) )
def is_bundle_path(path: Path) -> bool:
"""Check if a path looks like a bundle file."""
return path.name.lower().endswith(BUNDLE_EXTENSION)
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None: def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
"""Add in-memory bytes to a tar archive with deterministic metadata.""" """Add in-memory bytes to a tar archive with deterministic metadata."""
info = tarfile.TarInfo(name=name) info = tarfile.TarInfo(name=name)
+2
View File
@@ -49,11 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401
build_registry_entry, build_registry_entry,
build_registry_list, build_registry_list,
extract_registry_entry_config, extract_registry_entry_config,
get_slot_count,
gpio_pin_expression, gpio_pin_expression,
past_safe_mode, past_safe_mode,
register_component, register_component,
register_parented, register_parented,
set_setup_priority, set_setup_priority,
slot_counter,
) )
from esphome.cpp_types import ( # noqa: F401 from esphome.cpp_types import ( # noqa: F401
NAN, NAN,
+90 -23
View File
@@ -1,48 +1,69 @@
"""Validated-config cache for the upload/logs fast path. """Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml; compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
the next upload/logs for that YAML reuses it instead of running the full 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 read_config pipeline. The cache is deliberately lossy: only ``!lambda``
!lambda/!include/IDs/paths intact; mtime gates staleness. 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 from __future__ import annotations
import json
import logging import logging
from pathlib import Path 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, Lambda
from esphome.helpers import write_file from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__) _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: def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar.""" """Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml" return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
"""True iff the cache file exists and isn't older than the source."""
try:
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
except OSError:
return False
def save_compiled_config(config: ConfigType) -> None: def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh. """Write the validated-config cache. Always-write so mtime stays fresh.
Mode 0600 because show_secrets=True resolves !secret inline. Mode 0600 because config validation resolved !secret inline.
Failures are non-fatal: the fast path falls back to read_config. Failures are non-fatal: the fast path falls back to read_config.
""" """
from esphome import yaml_util
try: try:
rendered = yaml_util.dump(config, show_secrets=True) # 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) 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 except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err) _LOGGER.debug("Skipping compiled config cache write: %s", err)
@@ -51,18 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE. """Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, or the sidecar missing, older than the source YAML, unparseable, a different cache
is incomplete. 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) cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path): if not _cache_is_fresh(cache_path, conf_path):
return None return None
from esphome import yaml_util
try: try:
config = yaml_util.load_yaml(cache_path, clear_secrets=False) envelope = json.loads(
except Exception: # noqa: BLE001 # pylint: disable=broad-except 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 return None
storage = StorageJSON.load(ext_storage_path(conf_path.name)) storage = StorageJSON.load(ext_storage_path(conf_path.name))
@@ -74,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
return None return None
storage.apply_to_core() storage.apply_to_core()
return config return config
# Remove before 2027.8: by then every maintained install has saved the
# JSON cache at least once and dropped its legacy YAML file.
def _legacy_compiled_config_path(config_filename: str) -> Path:
"""Path of the pre-JSON YAML cache; only ever removed."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
"""True iff the cache file exists and isn't older than the source."""
try:
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
except OSError:
return False
def _json_default(value: Any) -> Any:
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
IncludeFile/Extend/Remove have no JSON mirror and would stringify
wrong, but none survive validation (config.py's packages merge and
the substitution pass consume them) so no guard is spent on them.
"""
if isinstance(value, Lambda):
return {_LAMBDA_KEY: value.value}
return str(value)
def _decode_object(obj: dict[str, Any]) -> Any:
"""Revive the Lambda sentinel; every other mapping passes through."""
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
return Lambda(value)
return obj
+1
View File
@@ -231,6 +231,7 @@ def validate_adc_pin(value):
return pins.internal_gpio_input_pin_schema(29) return pins.internal_gpio_input_pin_schema(29)
return cv.only_on([PLATFORM_ESP8266])("VCC") 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": if str(value).upper() == "TEMPERATURE":
return cv.only_on_rp2("TEMPERATURE") return cv.only_on_rp2("TEMPERATURE")
+20 -1
View File
@@ -19,6 +19,25 @@ namespace esphome::adc {
static const char *const TAG = "adc.rp2"; static const char *const TAG = "adc.rp2";
// 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 <pico.h> 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() { void ADCSensor::setup() {
static bool initialized = false; static bool initialized = false;
if (!initialized) { if (!initialized) {
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
if (this->is_temperature_) { if (this->is_temperature_) {
adc_set_temp_sensor_enabled(true); adc_set_temp_sensor_enabled(true);
delay(1); delay(1);
adc_select_input(4); adc_select_input(TEMPERATURE_ADC_INPUT);
for (uint8_t sample = 0; sample < this->sample_count_; sample++) { for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
raw = adc_read(); raw = adc_read();
+24 -6
View File
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import ( from esphome.components.zephyr import (
zephyr_add_overlay, zephyr_add_overlay,
zephyr_add_overlay_builder,
zephyr_add_prj_conf, zephyr_add_prj_conf,
zephyr_add_user,
) )
from esphome.config_helpers import filter_source_files_from_platform from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv import esphome.config_validation as cv
@@ -67,6 +67,13 @@ def validate_config(config):
# Alter value here so `config` command prints the recommended change # Alter value here so `config` command prints the recommended change
config[CONF_ATTENUATION] = _attenuation("12db") 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 return config
@@ -113,6 +120,18 @@ CONFIG_SCHEMA = cv.All(
CONF_ADC_CHANNEL_ID = "adc_channel_id" CONF_ADC_CHANNEL_ID = "adc_channel_id"
def _overlay_io_channels():
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): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
@@ -121,6 +140,7 @@ async def to_code(config):
if config[CONF_PIN] == "VCC": if config[CONF_PIN] == "VCC":
cg.add_define("USE_ADC_SENSOR_VCC") cg.add_define("USE_ADC_SENSOR_VCC")
elif config[CONF_PIN] == "TEMPERATURE": elif config[CONF_PIN] == "TEMPERATURE":
# Remove before 2027.2.0
cg.add(var.set_is_temperature()) cg.add(var.set_is_temperature())
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
pin = await cg.gpio_pin_expression(config[CONF_PIN]) pin = await cg.gpio_pin_expression(config[CONF_PIN])
@@ -173,9 +193,8 @@ async def to_code(config):
if isinstance(pin_number, int): if isinstance(pin_number, int):
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()} GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
pin_number = GPIO_TO_AIN[pin_number] pin_number = GPIO_TO_AIN[pin_number]
zephyr_add_user("io-channels", f"<&adc {channel_id}>") zephyr_add_overlay_builder(_overlay_io_channels)
zephyr_add_overlay( zephyr_add_overlay(f"""
f"""
&adc {{ &adc {{
#address-cells = <1>; #address-cells = <1>;
#size-cells = <0>; #size-cells = <0>;
@@ -190,8 +209,7 @@ async def to_code(config):
zephyr,oversampling = <8>; zephyr,oversampling = <8>;
}}; }};
}}; }};
""" """)
)
FILTER_SOURCE_FILES = filter_source_files_from_platform( FILTER_SOURCE_FILES = filter_source_files_from_platform(
+12 -9
View File
@@ -1,23 +1,26 @@
import esphome.codegen as cg 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 import esphome.config_validation as cv
from esphome.const import CONF_ID from esphome.const import CONF_ID
DEPENDENCIES = ["esp32_ble_tracker"] AUTO_LOAD = ["ble_device_base"]
CODEOWNERS = ["@jeromelaban"] CODEOWNERS = ["@jeromelaban"]
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
AirthingsListener = airthings_ble_ns.class_( AirthingsListener = airthings_ble_ns.class_(
"AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener "AirthingsListener", ble_device_base.ESPBTDeviceListener
) )
CONFIG_SCHEMA = cv.Schema( CONFIG_SCHEMA = cv.All(
{ ble_device_base.rename_legacy_hub_id("airthings_ble"),
cv.GenerateID(): cv.declare_id(AirthingsListener), cv.Schema(
} {
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) cv.GenerateID(): cv.declare_id(AirthingsListener),
}
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
)
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) 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)
@@ -2,15 +2,13 @@
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include <cinttypes> #include <cinttypes>
#ifdef USE_ESP32
namespace esphome::airthings_ble { namespace esphome::airthings_ble {
static const char *const TAG = "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()) { 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) if (it.data.size() < 4)
continue; continue;
@@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
} }
} // namespace esphome::airthings_ble } // namespace esphome::airthings_ble
#endif
@@ -1,17 +1,13 @@
#pragma once #pragma once
#ifdef USE_ESP32
#include "esphome/core/component.h" #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 { namespace esphome::airthings_ble {
class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener { class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
public: 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 } // namespace esphome::airthings_ble
#endif
+1 -1
View File
@@ -13,6 +13,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE, CONF_CAPTURE_RESPONSE,
CONF_DATA, CONF_DATA,
CONF_DATA_TEMPLATE, CONF_DATA_TEMPLATE,
CONF_ENCRYPTION,
CONF_EVENT, CONF_EVENT,
CONF_ID, CONF_ID,
CONF_KEY, CONF_KEY,
@@ -102,7 +103,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
for name, t in _SERVICE_ARG_SCALAR_TYPES.items() for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
}, },
} }
CONF_ENCRYPTION = "encryption"
CONF_BATCH_DELAY = "batch_delay" CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services" CONF_CUSTOM_SERVICES = "custom_services"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
+94 -22
View File
@@ -19,6 +19,7 @@ service APIConnection {
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) { rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false; option (needs_authentication) = false;
} }
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc list_entities (ListEntitiesRequest) returns (void) {} rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -243,6 +244,12 @@ message SerialProxyInfo {
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) // 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 { message DeviceInfoResponse {
option (id) = 10; option (id) = 10;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
@@ -280,6 +287,8 @@ message DeviceInfoResponse {
// Deprecated in API version 1.9 // Deprecated in API version 1.9
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; 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"]; uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true]; string manufacturer = 12 [(max_data_length) = 20, (force) = true];
@@ -288,11 +297,14 @@ message DeviceInfoResponse {
// Deprecated in API version 1.10 // Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; 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"]; 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"]; 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" // 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"]; string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key // Supports receiving and saving api encryption key
@@ -305,10 +317,13 @@ message DeviceInfoResponse {
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported // 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"]; 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"]; uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Serial proxy instance metadata // 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"]; 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 // Device is unprovisioned and accepts Noise handshakes with the well-known
@@ -317,6 +332,63 @@ message DeviceInfoResponse {
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
} }
// ==================== DEVICE CAPABILITIES ====================
// Asks the device which optional features it supports.
//
// This message exists so that DeviceInfoResponse does not have to keep growing
// a flat list of feature flags. DeviceInfoResponse is served before
// authentication, so it is limited to identity information. Capabilities are
// only served on an authenticated connection (encrypted as well, when
// encryption is configured).
//
// Clients that see api_version >= 1.15 should read these values from
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
// Older clients keep reading DeviceInfoResponse, which still carries the same
// values, so this is not a breaking change.
message DeviceCapabilitiesRequest {
option (id) = 149;
option (source) = SOURCE_CLIENT;
// Empty
}
// Each feature gets its own sub-message so that it can gain fields over time
// without crowding the top-level field numbering.
//
// Note: a sub-message whose fields are all at their default value is not sent
// at all, so the presence of a sub-message is not a reliable test for "this
// feature is compiled in". Clients should test a value inside it, for example
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
message BluetoothProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
string mac_address = 2 [(max_data_length) = 17, (force) = true];
}
message VoiceAssistantCapabilities {
// Bitmask of the features this voice assistant supports
uint32 feature_flags = 1;
}
message ZWaveProxyCapabilities {
// Bitmask of the features this proxy supports
uint32 feature_flags = 1;
uint32 home_id = 2;
}
message DeviceCapabilitiesResponse {
option (id) = 150;
option (source) = SOURCE_SERVER;
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
repeated SerialProxyInfo serial_proxies = 4
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
}
message ListEntitiesRequest { message ListEntitiesRequest {
option (id) = 11; option (id) = 11;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
@@ -1688,7 +1760,7 @@ enum BluetoothDeviceRequestType {
message BluetoothDeviceRequest { message BluetoothDeviceRequest {
option (id) = 68; option (id) = 68;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
BluetoothDeviceRequestType request_type = 2; BluetoothDeviceRequestType request_type = 2;
@@ -1699,7 +1771,7 @@ message BluetoothDeviceRequest {
message BluetoothDeviceConnectionResponse { message BluetoothDeviceConnectionResponse {
option (id) = 69; option (id) = 69;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
bool connected = 2; bool connected = 2;
@@ -1710,7 +1782,7 @@ message BluetoothDeviceConnectionResponse {
message BluetoothGATTGetServicesRequest { message BluetoothGATTGetServicesRequest {
option (id) = 70; option (id) = 70;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
} }
@@ -1754,7 +1826,7 @@ message BluetoothGATTService {
message BluetoothGATTGetServicesResponse { message BluetoothGATTGetServicesResponse {
option (id) = 71; option (id) = 71;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
repeated BluetoothGATTService services = 2; repeated BluetoothGATTService services = 2;
@@ -1763,7 +1835,7 @@ message BluetoothGATTGetServicesResponse {
message BluetoothGATTGetServicesDoneResponse { message BluetoothGATTGetServicesDoneResponse {
option (id) = 72; option (id) = 72;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
} }
@@ -1771,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse {
message BluetoothGATTReadRequest { message BluetoothGATTReadRequest {
option (id) = 73; option (id) = 73;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1780,7 +1852,7 @@ message BluetoothGATTReadRequest {
message BluetoothGATTReadResponse { message BluetoothGATTReadResponse {
option (id) = 74; option (id) = 74;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1792,7 +1864,7 @@ message BluetoothGATTReadResponse {
message BluetoothGATTWriteRequest { message BluetoothGATTWriteRequest {
option (id) = 75; option (id) = 75;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1804,7 +1876,7 @@ message BluetoothGATTWriteRequest {
message BluetoothGATTReadDescriptorRequest { message BluetoothGATTReadDescriptorRequest {
option (id) = 76; option (id) = 76;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1813,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest {
message BluetoothGATTWriteDescriptorRequest { message BluetoothGATTWriteDescriptorRequest {
option (id) = 77; option (id) = 77;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1824,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest {
message BluetoothGATTNotifyRequest { message BluetoothGATTNotifyRequest {
option (id) = 78; option (id) = 78;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1834,7 +1906,7 @@ message BluetoothGATTNotifyRequest {
message BluetoothGATTNotifyDataResponse { message BluetoothGATTNotifyDataResponse {
option (id) = 79; option (id) = 79;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1845,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse {
message SubscribeBluetoothConnectionsFreeRequest { message SubscribeBluetoothConnectionsFreeRequest {
option (id) = 80; option (id) = 80;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
} }
message BluetoothConnectionsFreeResponse { message BluetoothConnectionsFreeResponse {
option (id) = 81; option (id) = 81;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint32 free = 1; uint32 free = 1;
uint32 limit = 2; uint32 limit = 2;
@@ -1864,7 +1936,7 @@ message BluetoothConnectionsFreeResponse {
message BluetoothGATTErrorResponse { message BluetoothGATTErrorResponse {
option (id) = 82; option (id) = 82;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1874,7 +1946,7 @@ message BluetoothGATTErrorResponse {
message BluetoothGATTWriteResponse { message BluetoothGATTWriteResponse {
option (id) = 83; option (id) = 83;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1883,7 +1955,7 @@ message BluetoothGATTWriteResponse {
message BluetoothGATTNotifyResponse { message BluetoothGATTNotifyResponse {
option (id) = 84; option (id) = 84;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 handle = 2; uint32 handle = 2;
@@ -1892,7 +1964,7 @@ message BluetoothGATTNotifyResponse {
message BluetoothDevicePairingResponse { message BluetoothDevicePairingResponse {
option (id) = 85; option (id) = 85;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
bool paired = 2; bool paired = 2;
@@ -1902,7 +1974,7 @@ message BluetoothDevicePairingResponse {
message BluetoothDeviceUnpairingResponse { message BluetoothDeviceUnpairingResponse {
option (id) = 86; option (id) = 86;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
bool success = 2; bool success = 2;
@@ -1918,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
message BluetoothDeviceClearCacheResponse { message BluetoothDeviceClearCacheResponse {
option (id) = 88; option (id) = 88;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
bool success = 2; bool success = 2;
@@ -2735,7 +2807,7 @@ message SerialProxyRequestResponse {
message BluetoothSetConnectionParamsRequest { message BluetoothSetConnectionParamsRequest {
option (id) = 145; option (id) = 145;
option (source) = SOURCE_CLIENT; option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
uint32 min_interval = 2; // units of 1.25ms uint32 min_interval = 2; // units of 1.25ms
@@ -2747,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest {
message BluetoothSetConnectionParamsResponse { message BluetoothSetConnectionParamsResponse {
option (id) = 146; option (id) = 146;
option (source) = SOURCE_SERVER; option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY"; option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1; uint64 address = 1;
int32 error = 2; int32 error = 2;
+110 -24
View File
@@ -23,6 +23,7 @@
#include "esphome/core/application.h" #include "esphome/core/application.h"
#include "esphome/core/entity_base.h" #include "esphome/core/entity_base.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "esphome/core/version.h" #include "esphome/core/version.h"
#ifdef USE_PROVISIONING #ifdef USE_PROVISIONING
@@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
static const char *const TAG = "api.connection"; 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 #ifdef USE_CAMERA
static const int CAMERA_STOP_STREAM = 5000; static const int CAMERA_STOP_STREAM = 5000;
#endif #endif
@@ -440,7 +448,7 @@ void APIConnection::on_disconnect_response() {
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg, uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
CalculateSizeFn size_fn, MessageEncodeFn encode_fn, CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) { APIConnection *conn, uint32_t remaining_size) {
msg.key = entity->get_object_id_hash(); msg.key = entity->get_entity_key();
#ifdef USE_DEVICES #ifdef USE_DEVICES
msg.device_id = entity->get_device_id(); msg.device_id = entity->get_device_id();
#endif #endif
@@ -451,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
CalculateSizeFn size_fn, MessageEncodeFn encode_fn, CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
APIConnection *conn, uint32_t remaining_size) { APIConnection *conn, uint32_t remaining_size) {
// Set common fields that are shared by all entity types // Set common fields that are shared by all entity types
msg.key = entity->get_object_id_hash(); msg.key = entity->get_entity_key();
if (entity->has_own_name()) { if (entity->has_own_name()) {
msg.name = entity->get_name(); msg.name = entity->get_name();
@@ -1141,7 +1149,7 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send; bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg; CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_object_id_hash(); msg.key = camera::Camera::instance()->get_entity_key();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send); msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done; msg.done = done;
#ifdef USE_DEVICES #ifdef USE_DEVICES
@@ -1235,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
} }
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
} }
@@ -1268,13 +1277,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) { void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); 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 #endif
#ifdef USE_VOICE_ASSISTANT #ifdef USE_VOICE_ASSISTANT
@@ -1532,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
#endif #endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) #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 #endif
#ifdef USE_SERIAL_PROXY #ifdef USE_SERIAL_PROXY
@@ -1543,8 +1560,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure
static_cast<uint32_t>(proxies.size())); static_cast<uint32_t>(proxies.size()));
return; return;
} }
proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits, proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.data_size); msg.stop_bits, msg.data_size);
} }
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1553,7 +1570,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return; 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) { void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
@@ -1562,7 +1579,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return; return;
} }
proxies[msg.instance]->set_modem_pins(msg.line_states); proxies[msg.instance]->set_modem_pins(this, msg.line_states);
} }
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
@@ -1574,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
SerialProxyGetModemPinsResponse resp{}; SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance; resp.instance = msg.instance;
resp.line_states = proxies[msg.instance]->get_modem_pins(); resp.line_states = proxies[msg.instance]->get_modem_pins();
this->send_message(resp); if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
} }
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
@@ -1606,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
resp.status = enums::SERIAL_PROXY_STATUS_ERROR; resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break; break;
} }
this->send_message(resp); if (!this->send_message(resp)) {
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
}
break; break;
} }
default: default:
@@ -1615,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
} }
} }
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
if (!this->send_message(msg)) {
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
}
}
#endif #endif
#ifdef USE_INFRARED #ifdef USE_INFRARED
@@ -1735,7 +1760,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp; HelloResponse resp;
resp.api_version_major = 1; resp.api_version_major = 1;
resp.api_version_minor = 14; resp.api_version_minor = 15;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise // 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.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name()); resp.name = StringRef(App.get_name());
@@ -1746,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Acknowledge the hello so the client can read the server name, then request // Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed. // disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); 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; DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req); return this->send_message(req);
@@ -1771,9 +1798,8 @@ bool APIConnection::send_device_info_response_() {
#ifdef USE_AREAS #ifdef USE_AREAS
resp.suggested_area = StringRef(App.get_area()); resp.suggested_area = StringRef(App.get_area());
#endif #endif
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char mac_address[18]; uint8_t mac[MAC_ADDRESS_SIZE];
uint8_t mac[6];
get_mac_address_raw(mac); get_mac_address_raw(mac);
format_mac_addr_upper(mac, mac_address); format_mac_addr_upper(mac, mac_address);
resp.mac_address = StringRef(mac_address); resp.mac_address = StringRef(mac_address);
@@ -1849,8 +1875,7 @@ bool APIConnection::send_device_info_response_() {
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); 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[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
char bluetooth_mac[18];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_mac_address = StringRef(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac);
#endif #endif
@@ -1904,6 +1929,35 @@ bool APIConnection::send_device_info_response_() {
return this->send_message(resp); 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();
}
#endif
return this->send_message(resp);
}
void APIConnection::on_hello_request(const HelloRequest &msg) { void APIConnection::on_hello_request(const HelloRequest &msg) {
if (!this->send_hello_response_(msg)) { if (!this->send_hello_response_(msg)) {
this->on_fatal_error(); this->on_fatal_error();
@@ -1925,6 +1979,11 @@ void APIConnection::on_device_info_request() {
this->on_fatal_error(); 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 #ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
@@ -2003,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.call_id = call_id; resp.call_id = call_id;
resp.success = success; resp.success = success;
resp.error_message = error_message; 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 #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message, void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
@@ -2014,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.error_message = error_message; resp.error_message = error_message;
resp.response_data = response_data; resp.response_data = response_data;
resp.response_data_len = response_data_len; 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_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif #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 #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
@@ -2094,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
if (this->helper_->can_write_without_blocking()) if (this->helper_->can_write_without_blocking())
return true; return true;
if (log_out_of_space) { 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; return false;
} }
+22 -13
View File
@@ -25,6 +25,7 @@
#include "esphome/components/esp8266/crash_handler.h" #include "esphome/components/esp8266/crash_handler.h"
#endif #endif
#include "esphome/core/entity_base.h" #include "esphome/core/entity_base.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h" #include "esphome/core/string_ref.h"
#include <functional> #include <functional>
@@ -40,6 +41,16 @@ namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer; 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 // Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending // Maximum number of entities to process in a single batch during initial state/info sending
@@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase {
// Returns whether this client has subscribed to Home Assistant actions; the message // 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 // 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. // delivery - it lets the caller warn when no connected client has the subscription.
bool send_homeassistant_action(const HomeassistantActionRequest &call) { bool send_homeassistant_action(const HomeassistantActionRequest &call);
if (!this->flags_.service_call_subscription)
return false;
this->send_message(call);
return true;
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg); void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg); void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
void on_unsubscribe_bluetooth_le_advertisements_request(); void on_unsubscribe_bluetooth_le_advertisements_request();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg); void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg); void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg); void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
@@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg); void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg); void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
void on_subscribe_bluetooth_connections_free_request(); 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); void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
#endif
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
#endif #endif
#ifdef USE_HOMEASSISTANT_TIME #ifdef USE_HOMEASSISTANT_TIME
void send_time_request() { void send_time_request();
GetTimeRequest req;
this->send_message(req);
}
#endif #endif
#ifdef USE_VOICE_ASSISTANT #ifdef USE_VOICE_ASSISTANT
@@ -266,6 +271,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_disconnect_request(const DisconnectRequest &msg); void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request(); void on_ping_request();
void on_device_info_request(); void on_device_info_request();
void on_device_capabilities_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() { void on_subscribe_states_request() {
this->flags_.state_subscription = true; this->flags_.state_subscription = true;
@@ -334,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for type-erased size calculation // Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *); using CalculateSizeFn = uint32_t (*)(const void *);
template<typename T> 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<typename T> [[nodiscard]] bool send_message(const T &msg) {
if constexpr (T::ESTIMATED_SIZE == 0) { if constexpr (T::ESTIMATED_SIZE == 0) {
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
} else { } else {
@@ -385,10 +393,11 @@ class APIConnection final : public APIServerConnectionBase {
bool send_disconnect_response_(); bool send_disconnect_response_();
bool send_ping_response_(); bool send_ping_response_();
bool send_device_info_response_(); bool send_device_info_response_();
bool send_device_capabilities_response_();
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool send_subscribe_bluetooth_connections_free_response_(); bool send_subscribe_bluetooth_connections_free_response_();
#endif #endif
#ifdef USE_VOICE_ASSISTANT #ifdef USE_VOICE_ASSISTANT
+19 -2
View File
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
} }
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) { ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
// socket->write() can re-enter this function: a log message emitted from an
// lwip callback during the write goes out over the API and lands back in the
// frame helper's write/drain path. If a nested drain ran here it would send
// and free the entry the outer drain is still holding, causing a double free.
// Report "no progress" instead; the outer drain keeps draining, and the
// nested send is enqueued behind the existing backlog.
if (this->draining_)
return 0;
// RAII so the flag is cleared on every return path
struct DrainGuard {
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
~DrainGuard() { this->flag_ = false; }
bool &flag_;
} guard(this->draining_);
while (this->count_ > 0) { while (this->count_ > 0) {
Entry *front = this->queue_[this->head_]; Entry *front = this->queue_[this->head_];
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
return sent; return sent;
} }
// Entry fully sent — free it and advance // Entry fully sent — unlink it before freeing so a freed pointer is never
Entry::destroy(front); // reachable from the queue
this->queue_[this->head_] = nullptr; this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE; this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--; this->count_--;
Entry::destroy(front);
} }
return 0; // All drained return 0; // All drained
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
uint8_t head_{0}; uint8_t head_{0};
uint8_t tail_{0}; uint8_t tail_{0};
uint8_t count_{0}; uint8_t count_{0};
// Guards against re-entrant drains: socket->write() can re-enter the API
// send path (e.g. a log message emitted from an lwip callback), and a nested
// drain would free the entry the outer drain is still holding.
bool draining_{false};
}; };
} // namespace esphome::api } // namespace esphome::api
+81 -1
View File
@@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const {
#endif #endif
return size; 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 #ifdef USE_BINARY_SENSOR
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos(); uint8_t *__restrict__ pos = buffer.get_pos();
@@ -2406,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
} }
return size; return size;
} }
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) { switch (field_id) {
case 1: case 1:
@@ -2782,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
size += ProtoSize::calc_int32(1, this->error); size += ProtoSize::calc_int32(1, this->error);
return size; return size;
} }
#endif
#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos(); uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state)); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state));
@@ -4145,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
return size; return size;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) { switch (field_id) {
case 1: case 1:
+76 -2
View File
@@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1, MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
}; };
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
enum BluetoothDeviceRequestType : uint32_t { enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0, BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1, BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
@@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5, BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6, BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
}; };
#endif
#ifdef USE_BLUETOOTH_PROXY
enum BluetoothScannerState : uint32_t { enum BluetoothScannerState : uint32_t {
BLUETOOTH_SCANNER_STATE_IDLE = 0, BLUETOOTH_SCANNER_STATE_IDLE = 0,
BLUETOOTH_SCANNER_STATE_STARTING = 1, BLUETOOTH_SCANNER_STATE_STARTING = 1,
@@ -600,6 +602,74 @@ class DeviceInfoResponse final : public ProtoMessage {
protected: 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 uint8_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<SerialProxyInfo, SERIAL_PROXY_COUNT> 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 { class ListEntitiesDoneResponse final : public ProtoMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 19; static constexpr uint8_t MESSAGE_TYPE = 19;
@@ -1931,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
protected: protected:
}; };
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothDeviceRequest final : public ProtoDecodableMessage { class BluetoothDeviceRequest final : public ProtoDecodableMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 68; static constexpr uint8_t MESSAGE_TYPE = 68;
@@ -2316,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
protected: protected:
}; };
#endif
#ifdef USE_BLUETOOTH_PROXY
class BluetoothScannerStateResponse final : public ProtoMessage { class BluetoothScannerStateResponse final : public ProtoMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 126; static constexpr uint8_t MESSAGE_TYPE = 126;
@@ -3290,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected: protected:
}; };
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
public: public:
static constexpr uint8_t MESSAGE_TYPE = 145; static constexpr uint8_t MESSAGE_TYPE = 145;
+1 -1
View File
@@ -3,7 +3,7 @@
#pragma once #pragma once
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#ifdef USE_BLUETOOTH_PROXY #if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
#ifndef USE_API_VARINT64 #ifndef USE_API_VARINT64
#define USE_API_VARINT64 #define USE_API_VARINT64
#endif #endif
+57 -2
View File
@@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string<enums::MediaPlayerFormatPurpose>(enu
} }
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
template<> template<>
const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) { const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) {
switch (value) { switch (value) {
@@ -606,6 +606,8 @@ const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::Bluet
return ESPHOME_PSTR("UNKNOWN"); return ESPHOME_PSTR("UNKNOWN");
} }
} }
#endif
#ifdef USE_BLUETOOTH_PROXY
template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) { template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) {
switch (value) { switch (value) {
case enums::BLUETOOTH_SCANNER_STATE_IDLE: case enums::BLUETOOTH_SCANNER_STATE_IDLE:
@@ -988,6 +990,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#endif #endif
return out.c_str(); 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 { const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
return out.c_str(); return out.c_str();
@@ -1953,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
} }
return out.c_str(); return out.c_str();
} }
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const { const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest")); MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address); dump_field(out, ESPHOME_PSTR("address"), this->address);
@@ -2124,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error"), this->error); dump_field(out, ESPHOME_PSTR("error"), this->error);
return out.c_str(); return out.c_str();
} }
#endif
#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const { const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse")); MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state)); dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state));
@@ -2715,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
return out.c_str(); return out.c_str();
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest")); MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address); dump_field(out, ESPHOME_PSTR("address"), this->address);
+16 -9
View File
@@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothDeviceRequest::MESSAGE_TYPE: { case BluetoothDeviceRequest::MESSAGE_TYPE: {
BluetoothDeviceRequest msg; BluetoothDeviceRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: { case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: {
BluetoothGATTGetServicesRequest msg; BluetoothGATTGetServicesRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTReadRequest::MESSAGE_TYPE: { case BluetoothGATTReadRequest::MESSAGE_TYPE: {
BluetoothGATTReadRequest msg; BluetoothGATTReadRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTWriteRequest::MESSAGE_TYPE: { case BluetoothGATTWriteRequest::MESSAGE_TYPE: {
BluetoothGATTWriteRequest msg; BluetoothGATTWriteRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: { case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: {
BluetoothGATTReadDescriptorRequest msg; BluetoothGATTReadDescriptorRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: { case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: {
BluetoothGATTWriteDescriptorRequest msg; BluetoothGATTWriteDescriptorRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothGATTNotifyRequest::MESSAGE_TYPE: { case BluetoothGATTNotifyRequest::MESSAGE_TYPE: {
BluetoothGATTNotifyRequest msg; BluetoothGATTNotifyRequest msg;
msg.decode(msg_data, msg_size); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP #ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); 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; break;
} }
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: { case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: {
BluetoothSetConnectionParamsRequest msg; BluetoothSetConnectionParamsRequest msg;
msg.decode(msg_data, msg_size); msg.decode(msg_data, msg_size);
@@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break; break;
} }
#endif #endif
case 149 /* DeviceCapabilitiesRequest is empty */: {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
#endif
this->on_device_capabilities_request();
break;
}
default: default:
break; break;
} }
+11 -9
View File
@@ -27,6 +27,8 @@ class APIServerConnectionBase {
void on_ping_response(){}; void on_ping_response(){};
void on_device_info_request(){}; void on_device_info_request(){};
void on_device_capabilities_request(){};
void on_list_entities_request(){}; void on_list_entities_request(){};
void on_subscribe_states_request(){}; void on_subscribe_states_request(){};
@@ -113,32 +115,32 @@ class APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){}; void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &value){}; void on_bluetooth_device_request(const BluetoothDeviceRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){}; void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){}; void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){}; void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){}; void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){}; void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){}; void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_subscribe_bluetooth_connections_free_request(){}; void on_subscribe_bluetooth_connections_free_request(){};
#endif #endif
@@ -233,7 +235,7 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){}; void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif #endif
#ifdef USE_BLUETOOTH_PROXY #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif #endif
}; };
+11 -4
View File
@@ -123,7 +123,9 @@ void APIServer::setup() {
// Best-effort: if the send buffer is full the reason is dropped, but the // 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 // client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close. // 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) { 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 // 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 // very infrequent and small, we simply send it to all clients
for (auto &c : this->active_clients()) for (auto &c : this->active_clients()) {
c->send_message(msg); if (!c->send_message(msg)) {
API_LOG_MSG_DROPPED(TAG, "Home ID notification");
}
}
} }
#endif #endif
@@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
for (auto &c : this->active_clients()) { for (auto &c : this->active_clients()) {
DisconnectRequest req; DisconnectRequest req;
c->send_message(req); if (!c->send_message(req)) {
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
}
} }
}); });
} }
+7 -174
View File
@@ -1,177 +1,10 @@
from __future__ import annotations """Backward-compatibility shim; the log client lives in esphome.api_client.
import asyncio Importing this module executes the whole api component package, which pulls
from datetime import datetime in the validation stack. CLI code paths should import esphome.api_client
import importlib directly so the logs fast path stays light.
import logging """
from typing import TYPE_CHECKING, Any
import warnings
# Suppress protobuf version warnings from esphome.api_client import async_run_logs, run_logs
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
import contextlib __all__ = ["async_run_logs", "run_logs"]
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)
)
@@ -1,8 +1,6 @@
#include "atc_mithermometer.h" #include "atc_mithermometer.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::atc_mithermometer { namespace esphome::atc_mithermometer {
static const char *const TAG = "atc_mithermometer"; static const char *const TAG = "atc_mithermometer";
@@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() {
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_); 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_) { if (device.address_uint64() != this->address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false; return false;
@@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device
return success; return success;
} }
optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) { optional<ParseResult> ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) {
ParseResult result; ParseResult result;
if (!service_data.uuid.contains(0x1A, 0x18)) { if (!service_data.uuid.contains(0x1A, 0x18)) {
ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes."); ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes.");
@@ -132,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional<ParseResult> &result, cons
} }
} // namespace esphome::atc_mithermometer } // namespace esphome::atc_mithermometer
#endif
@@ -2,12 +2,10 @@
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.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 <vector> #include <vector>
#ifdef USE_ESP32
namespace esphome::atc_mithermometer { namespace esphome::atc_mithermometer {
struct ParseResult { struct ParseResult {
@@ -18,11 +16,11 @@ struct ParseResult {
int raw_offset; int raw_offset;
}; };
class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener { class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener {
public: public:
void set_address(uint64_t address) { address_ = address; }; 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 dump_config() override;
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } 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}; uint8_t last_frame_count_{0};
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data); optional<ParseResult> parse_header_(const ble_device_base::ServiceData &service_data);
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result); bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
bool report_results_(const optional<ParseResult> &result, const char *address); bool report_results_(const optional<ParseResult> &result, const char *address);
}; };
} // namespace esphome::atc_mithermometer } // namespace esphome::atc_mithermometer
#endif
@@ -1,5 +1,5 @@
import esphome.codegen as cg 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 import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_BATTERY_LEVEL, CONF_BATTERY_LEVEL,
@@ -24,14 +24,15 @@ from esphome.const import (
CODEOWNERS = ["@ahpohl"] CODEOWNERS = ["@ahpohl"]
DEPENDENCIES = ["esp32_ble_tracker"] AUTO_LOAD = ["ble_device_base"]
atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer") atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer")
ATCMiThermometer = atc_mithermometer_ns.class_( 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.Schema(
{ {
cv.GenerateID(): cv.declare_id(ATCMiThermometer), cv.GenerateID(): cv.declare_id(ATCMiThermometer),
@@ -71,15 +72,15 @@ CONFIG_SCHEMA = (
), ),
} }
) )
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
) )
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, 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_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
+3 -1
View File
@@ -371,7 +371,7 @@ async def to_code(config):
data.wav_support = True data.wav_support = True
if data.micro_decoder_support: 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 # 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: if not data.flac_support:
@@ -380,6 +380,8 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support: if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) 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: if not data.wav_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
+1 -5
View File
@@ -1,8 +1,6 @@
#include "b_parasite.h" #include "b_parasite.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::b_parasite { namespace esphome::b_parasite {
static const char *const TAG = "b_parasite"; static const char *const TAG = "b_parasite";
@@ -16,7 +14,7 @@ void BParasite::dump_config() {
LOG_SENSOR(" ", "Illuminance", this->illuminance_); 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_) { if (device.address_uint64() != address_) {
ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
return false; return false;
@@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
} }
} // namespace esphome::b_parasite } // namespace esphome::b_parasite
#endif // USE_ESP32
+3 -7
View File
@@ -2,18 +2,16 @@
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.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"
#ifdef USE_ESP32
namespace esphome::b_parasite { 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: public:
void set_address(uint64_t address) { address_ = address; }; void set_address(uint64_t address) { address_ = address; };
void set_bindkey(const std::string &bindkey); 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 dump_config() override;
void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; } 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 } // namespace esphome::b_parasite
#endif // USE_ESP32
+7 -6
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg 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 import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_BATTERY_VOLTAGE, CONF_BATTERY_VOLTAGE,
@@ -23,14 +23,15 @@ from esphome.const import (
CODEOWNERS = ["@rbaron"] CODEOWNERS = ["@rbaron"]
DEPENDENCIES = ["esp32_ble_tracker"] AUTO_LOAD = ["ble_device_base"]
b_parasite_ns = cg.esphome_ns.namespace("b_parasite") b_parasite_ns = cg.esphome_ns.namespace("b_parasite")
BParasite = b_parasite_ns.class_( 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.Schema(
{ {
cv.GenerateID(): cv.declare_id(BParasite), cv.GenerateID(): cv.declare_id(BParasite),
@@ -68,15 +69,15 @@ CONFIG_SCHEMA = (
), ),
} }
) )
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
) )
async def to_code(config): async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, 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_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
+5 -1
View File
@@ -51,7 +51,11 @@ def _set_core_data(config):
return config 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 PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA
@@ -45,6 +45,11 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_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")
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
+119
View File
@@ -0,0 +1,119 @@
// Every SDK call the scan reconciler makes. The BDK's own start hardcodes
// passive (the active bit is commented out in both stacks), so
// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field
// the SDK's app_ble_start_scaning() except that prop takes the mode, armed
// through the SDK's own operation bookkeeping. The component pins
// beken-bdk 3.0.78; the static asserts catch a layout change on a bump.
#include "bdk_scan.h"
#ifdef USE_BK72XX_BLE
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
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<int>(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<int>(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<int>(ret));
return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED;
}
} // namespace esphome::bk72xx_ble
#endif // !CLANG_TIDY && ble_api.h
#endif // USE_BK72XX_BLE
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BK72XX_BLE
#include <cstdint>
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
+259 -36
View File
@@ -5,7 +5,8 @@
// talks to the Beken BDK BLE stack: // talks to the Beken BDK BLE stack:
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()), // - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
// - the controller BLE address, // - 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 // - 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, // 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 // dispatches on the main task and returns reports to the pool — the same
@@ -20,10 +21,13 @@
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE #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 #ifdef USE_BK72XX_BLE
#include <cstring> #include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/helpers.h" // get_mac_address_raw() #include "esphome/core/helpers.h" // get_mac_address_raw()
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -57,9 +61,8 @@
// are C headers consumed from C++ (a standard C-header-from-C++ pattern). // are C headers consumed from C++ (a standard C-header-from-C++ pattern).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
extern "C" { extern "C" {
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb, #include "ble_api.h" // ble_set_notice_cb, recv_adv_t, ble_notice_t,
// app_ble_get_idle_actv_idx_handle, struct scan_param, // BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp)
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR #ifdef BK72XX_BLE_HAS_COMMON_BDADDR
#include "common_bt_defines.h" // struct bd_addr #include "common_bt_defines.h" // struct bd_addr
// The controller's public BLE address, populated by the BDK during ble_entry(). // The controller's public BLE address, populated by the BDK during ble_entry().
@@ -76,6 +79,12 @@ namespace esphome::bk72xx_ble {
static const char *const TAG = "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, // 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. // so it reaches the (single) component instance through a file-static pointer.
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -95,21 +104,22 @@ static void ble_notice_callback(ble_notice_t notice, void *param) {
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param); const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for // 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). // a signed dBm value packed in a uint8_t).
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data, s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type,
info->data_len); static_cast<uint8_t>(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, void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type,
uint16_t data_len) { const uint8_t *data, uint16_t data_len) {
BLEScanReport *report = this->report_pool_.allocate(); BLEScanReport *report = this->report_pool_.allocate();
if (report == nullptr) { if (report == nullptr) {
// Pool exhausted — the queue is full; count and drop. // Pool exhausted — the queue is full; count and drop.
this->report_queue_.increment_dropped_count(); this->report_queue_.increment_dropped_count();
return; return;
} }
memcpy(report->mac, mac, 6); memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
report->rssi = rssi; report->rssi = rssi;
report->addr_type = addr_type; report->addr_type = addr_type;
report->evt_type = evt_type;
report->data_len = report->data_len =
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data)); (data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
memcpy(report->data, data, report->data_len); memcpy(report->data, data, report->data_len);
@@ -123,6 +133,9 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add
void BK72xxBLE::setup() { void BK72xxBLE::setup() {
s_ble = this; 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 // 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). // the stack is up (it is re-read once ble_entry() has run).
this->resolve_mac_(); this->resolve_mac_();
@@ -173,14 +186,40 @@ void BK72xxBLE::enable() {
} }
void BK72xxBLE::loop() { 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 // 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. // here on the main task, then the report returns to the pool.
BLEScanReport *report = this->report_queue_.pop(); BLEScanReport *report = this->report_queue_.pop();
if (report == nullptr) if (report == nullptr)
return; return;
do { do {
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
for (auto *listener : this->scan_listeners_) for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report); listener->on_scan_report(*report);
#endif
this->report_pool_.release(report); this->report_pool_.release(report);
} while ((report = this->report_queue_.pop()) != nullptr); } while ((report = this->report_queue_.pop()) != nullptr);
@@ -191,7 +230,7 @@ void BK72xxBLE::loop() {
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); 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++) for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i]; out[i] = this->ble_mac_[i];
} }
@@ -224,7 +263,7 @@ void BK72xxBLE::resolve_mac_() {
} }
} }
if (nonzero) { if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
return; return;
} }
#endif #endif
@@ -236,54 +275,238 @@ void BK72xxBLE::resolve_mac_() {
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it // (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 // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
// would carry differently. // would carry differently.
uint8_t wifi_mac[6]; uint8_t wifi_mac[MAC_ADDRESS_SIZE];
get_mac_address_raw(wifi_mac); // MSB-first get_mac_address_raw(wifi_mac); // MSB-first
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)}; wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
// Store LSB-first to match recv_adv_t adv_addr ordering. // Store LSB-first to match recv_adv_t adv_addr ordering.
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
this->ble_mac_[i] = ble[5 - 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()) if (!this->is_active())
this->enable(); this->enable();
if (this->scan_actv_idx_ != 0xFF) { const ScanParams params{active, interval, window};
// Already scanning — stop first so this call cleanly restarts with the new // A new episode refills the budget and gets a fresh teardown deadline; a
// parameters (the BDK cannot start a second scan on a busy activity). // re-call observing an in-flight bring-up (last result PENDING) must not.
this->scan_stop(); 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; void BK72xxBLE::scan_stop() {
memset(&sp, 0, sizeof(sp)); if (this->scan_wanted_) {
sp.channel_map = 7; // advertising channels 37/38/39 // A stamp inherited from a stuck restart would fail the stop on its
sp.interval = interval; // first advance.
sp.window = window; this->reset_teardown_episode_();
}
this->scan_wanted_ = false;
this->advance_();
}
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV); bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) {
if (this->scan_actv_idx_ == 0xFF) { // millis() on both sides: the loop clock is frozen while this blocks.
ESP_LOGE(TAG, "Scan start failed: no idle activity handle"); 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; return false;
} }
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr); if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS)
if (ret != ERR_SUCCESS) {
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
this->scan_actv_idx_ = 0xFF;
return false; 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; return true;
} }
void BK72xxBLE::scan_stop() { // One SDK operation per call toward the latched request; controller state is
if (this->scan_actv_idx_ != 0xFF) { // read live each time (it changes on the BLE task, so nothing is mirrored).
bk_ble_scan_stop(this->scan_actv_idx_, nullptr); // The epilogue owns all deadlines and episode bookkeeping.
this->scan_actv_idx_ = 0xFF; 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 } // namespace esphome::bk72xx_ble
+73 -13
View File
@@ -6,10 +6,12 @@
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/event_pool.h" #include "esphome/core/event_pool.h"
#include "esphome/core/helpers.h"
#include "esphome/core/lock_free_queue.h" #include "esphome/core/lock_free_queue.h"
#include <cstdint> #include <cstdint>
#include <vector>
#include "bdk_scan.h"
namespace esphome::bk72xx_ble { namespace esphome::bk72xx_ble {
@@ -19,11 +21,32 @@ enum class BLEComponentState : uint8_t {
ACTIVE, 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. /// One advertisement report from the controller.
struct BLEScanReport { struct BLEScanReport {
uint8_t mac[6]; // LSB-first, as the controller delivers it uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm int8_t rssi; // signed dBm
uint8_t addr_type; 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_len; // bytes valid in data[]
uint8_t data[62]; // legacy advertisement (31) + scan response (31) 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; } 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). /// 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()). /// 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); } 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). /// Request a scan (interval/window in 0.625 ms BLE units); enables the
/// Enables the stack first if needed. Returns false on controller failure. /// stack first if needed. PENDING until the scan is observed running —
bool scan_start(uint16_t interval, uint16_t window); /// loop() keeps advancing, call again to learn the outcome.
/// Stop the controller scan (no-op when not scanning). 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(); 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 /// Internal: buffer one controller report (BDK notice callback, BLE task
/// context — bounded copy under the scheduler lock, nothing else). /// 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: protected:
void resolve_mac_(); 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<BLEScanListener *> 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<BLEScanListener *, BK72XX_BLE_SCAN_LISTENER_COUNT> scan_listeners_;
#endif
// Report ring: the BDK notice callback (BLE task) allocates a report from the // 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. // pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern. // 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 // allocate() returns nullptr before push() can fail. This prevents leaking a
// pool slot on a failed push and keeps release() off the producer path. // pool slot on a failed push and keeps release() off the producer path.
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_; esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) // Largest-to-smallest: padding only at the tail, absorbed by future byte fields.
uint8_t scan_actv_idx_{0xFF}; uint32_t last_advance_ms_{0};
BLEComponentState state_{BLEComponentState::STATE_OFF}; 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}; 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 } // namespace esphome::bk72xx_ble
+130 -91
View File
@@ -12,19 +12,31 @@ Scan modes:
Use this when the radio is dedicated to BLE. Use this when the radio is dedicated to BLE.
continuous: false a started scan runs for `duration` ms, then stops. The continuous: false a started scan runs for `duration` ms, then stops. The
FIRST start is external too: nothing in this component FIRST start is external too: nothing in this component
starts a non-continuous scan on boot, so until the starts a non-continuous scan on boot the radio stays
automation actions land (follow-up PR) the radio stays idle until bk72xx_ble_tracker.start_scan fires (e.g.
idle. start_scan() is called from code (e.g. an api from an api client-connected automation), so the
client-connected automation) so the single-core radio single-core radio can service WiFi in between scans.
can service WiFi in between scans.
""" """
from esphome import automation
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import bk72xx_ble, ble_device_base, ota from esphome.components import bk72xx_ble, ble_device_base, ota
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW 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 import esphome.config_validation as cv
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority 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 from esphome.types import ConfigType
CONF_BK72XX_BLE_ID = "bk72xx_ble_id" CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
@@ -33,119 +45,146 @@ DEPENDENCIES = ["bk72xx"]
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
CODEOWNERS = ["@Bl00d-B0b"] CODEOWNERS = ["@Bl00d-B0b"]
ble_device_base.register_hub_provider("bk72xx_ble_tracker")
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker") bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_( BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component "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)
def to_ble_units(value: cv.TimePeriod) -> int: ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
"""Convert a scan time to the controller's 0.625 ms units. BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
Used by both validation and codegen so what is validated is exactly what is BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
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: # interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms
"""Reject impossible window/interval/duration combinations at config time. # 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 %.)
Mirrors esp32_ble_tracker: the controller cannot scan for longer than the SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms")
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 the 1/sec retry loop.
"""
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})"
)
# 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 (("interval", interval), ("window", window)):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {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)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both round 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
SCAN_PARAMETERS_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
# interval/window default to the BK reference scan rate — 100 ms / 30 ms,
# 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 %.)
cv.Optional(CONF_INTERVAL, default="100ms"): cv.positive_time_period,
cv.Optional(CONF_WINDOW, default="30ms"): cv.positive_time_period,
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
}
),
validate_scan_parameters,
)
CONFIG_SCHEMA = cv.Schema( CONFIG_SCHEMA = cv.Schema(
{ {
cv.GenerateID(): cv.declare_id(BK72xxBLETracker), cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE), cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA, 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) ).extend(cv.COMPONENT_SCHEMA)
# Runs at FINAL priority so every BLE sensor has registered through @automation.register_action(
# ble_device_base (and any tracker-owned listeners have been counted) before "bk72xx_ble_tracker.start_scan",
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker. StartScanAction,
@coroutine_with_priority(CoroPriority.FINAL) cv.Schema(
async def _emit_listener_count() -> None: {
count = ble_device_base.get_listener_count() cv.GenerateID(): cv.use_id(BK72xxBLETracker),
if count > 0: # Optional with no default, unlike esp32_ble_tracker: omitting it
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count) # 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: 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]) var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config) await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID]) parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID])
cg.add(var.set_parent(parent)) 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) # Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
ota.request_ota_state_listeners() ota.request_ota_state_listeners()
scan = config[CONF_SCAN_PARAMETERS] scan = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_interval(to_ble_units(scan[CONF_INTERVAL]))) cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
cg.add(var.set_scan_window(to_ble_units(scan[CONF_WINDOW]))) 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_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS])) cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
CORE.add_job(_emit_listener_count) 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)
@@ -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<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
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<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
public:
void play(const Ts &...x) override { this->parent_->stop_scan(); }
};
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
@@ -9,10 +9,9 @@
#include "bk72xx_ble_tracker.h" #include "bk72xx_ble_tracker.h"
#include <algorithm>
#include <cinttypes> #include <cinttypes>
#include "esphome/core/hal.h" #include "esphome/core/application.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
namespace esphome::bk72xx_ble_tracker { namespace esphome::bk72xx_ble_tracker {
@@ -27,6 +26,15 @@ static const char *const TAG = "bk72xx_ble_tracker";
// a single WARN is emitted when the retry interval first saturates. // a single WARN is emitted when the retry interval first saturates.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000; static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s 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 // Component lifecycle
@@ -36,11 +44,20 @@ void BK72xxBLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the // Receive the controller's scan reports; the controller queues them from the
// BLE task and delivers here on the main task. // BLE task and delivers here on the main task.
this->parent_->register_scan_listener(this); 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 #ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the // 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. // BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this); ota::get_global_ota_callback()->add_global_state_listener(this);
#endif #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 #ifdef USE_OTA_STATE_LISTENER
@@ -48,49 +65,64 @@ void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress,
ota::OTAComponent *comp) { ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) { if (state == ota::OTA_STARTED) {
this->scan_continuous_before_ota_ = this->scan_continuous_; this->scan_continuous_before_ota_ = this->scan_continuous_;
this->scan_requested_before_ota_ = this->scan_requested_;
this->stop_scan(); this->stop_scan();
} else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { // 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; // On success the device reboots, so restore only on a failed/aborted update;
// loop() restarts the scan on its next iteration (continuous idle branch). // loop() restarts the scan on its next iteration (continuous idle branch).
this->scan_continuous_before_ota_ = false; if (this->scan_continuous_before_ota_) {
this->scan_continuous_ = true; 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 #endif // USE_OTA_STATE_LISTENER
void BK72xxBLETracker::loop() { void BK72xxBLETracker::loop() {
const uint32_t now = millis(); 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_continuous_) {
if (!this->scan_running_) { if (!this->scan_running_) {
// Rate-limit (re)start attempts. The controller start can fail (no idle activity // One-iteration deferral; all stamps share this iteration's cached
// handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every // timestamp, so the period check below cannot underflow.
// main-loop iteration would spin the single-core CPU and starve WiFi (device if (this->try_start_with_backoff_(now))
// becomes unresponsive). The interval backs off with consecutive failures so a return;
// controller that never comes up polls slowly and quietly.
const uint8_t doublings = std::min<uint8_t>(this->failed_start_count_, SCAN_START_RETRY_MAX_DOUBLINGS);
if (now - this->last_scan_start_attempt_ >= (SCAN_START_RETRY_MS << doublings)) {
this->last_scan_start_attempt_ = now;
this->start_scan_();
if (this->scan_running_) {
this->failed_start_count_ = 0;
} else 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);
}
}
}
} }
// Period timer: fire on_scan_end() once per scan_duration_ window, mirroring // 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 // 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. // 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_) { if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->fire_scan_end_();
for (auto *listener : this->listeners_)
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity)
#endif
this->scan_period_start_ = now; this->scan_period_start_ = now;
} }
return; return;
@@ -98,21 +130,84 @@ void BK72xxBLETracker::loop() {
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end. // 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:). // 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_) { 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_(); 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() { void BK72xxBLETracker::dump_config() {
ESP_LOGCONFIG(TAG, ESP_LOGCONFIG(TAG,
"BK72xx BLE Tracker:\n" "BK72xx BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n" " Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n" " Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n" " Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n" " Scan Type: %s (configured %s)\n"
" Continuous Scanning: %s", " Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_, this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_,
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_)); 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_));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -121,29 +216,33 @@ void BK72xxBLETracker::dump_config() {
// listener dispatch run in main-loop context with no cross-task handling here. // listener dispatch run in main-loop context with no cross-task handling here.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) { // GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
// Raw callback (the raw-advertisement path). // bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
if (this->raw_advertisement_callback_.is_set()) { // 5.2 fill it from gapm_ext_adv_report_ind.info).
const ble_device_base::RawAdvertisement adv{.mac = report.mac, static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
.data = report.data, static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
.data_len = report.data_len, static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
.rssi = report.rssi, static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT // Demux advertisements vs scan responses into the shared merger: the BDK
ble_device_base::ESPBTDevice device; // delivers the pair as separate reports; a scannable advertisement is held
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len); // until its scan response arrives and delivered as one merged frame.
bool found = false; void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
for (auto *listener : this->listeners_) const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
if (listener->parse_device(device)) if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
found = true; this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed return;
// it and the scan is one-shot (continuous scans would spam). }
if (!found && !this->scan_continuous_) // Stash only while an active scan runs: a passive scan never gets a
this->discovered_log_.log_device(TAG, device); // response, and after a stop nothing would sweep the merger, so a late
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT // 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);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -153,13 +252,41 @@ void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
void BK72xxBLETracker::start_scan() { void BK72xxBLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via // Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning. // set_scan_continuous() first, then calls start_scan() to begin scanning.
if (!this->scan_running_) { //
this->start_scan_(); // 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() { void BK72xxBLETracker::stop_scan() {
this->scan_continuous_ = false; this->scan_continuous_ = false;
this->scan_requested_ = false; // also cancels a pending (not yet successful) start
this->stop_scan_(); this->stop_scan_();
} }
@@ -167,22 +294,31 @@ void BK72xxBLETracker::stop_scan() {
// Internal scan start / stop // 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<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
}
void BK72xxBLETracker::start_scan_() { void BK72xxBLETracker::start_scan_() {
if (this->scan_running_) if (this->scan_running_)
return; return;
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_), if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED)
static_cast<uint16_t>(this->scan_window_)))
return; return;
const uint32_t now = millis(); const uint32_t now = App.get_loop_component_start_time();
this->scan_running_ = true; 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; this->scan_start_time_ = now;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and // 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 // in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up. // would read as the scanner failing to come back up.
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f, ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
this->scan_interval_ * 0.625f); 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 // 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 // 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 // resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
@@ -192,18 +328,49 @@ void BK72xxBLETracker::start_scan_() {
this->scan_started_once_ = true; 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_() { void BK72xxBLETracker::stop_scan_() {
if (!this->scan_running_) this->start_attempt_open_ = false; // an abandoned bring-up is not charged
return; this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
this->parent_->scan_stop(); 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->scan_running_ = false;
ESP_LOGD(TAG, "Scan stopped"); this->fire_scan_end_();
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->scan_period_start_ = now;
for (auto *listener : this->listeners_) }
listener->on_scan_end();
this->discovered_log_.clear(); // reset per-scan "Found device" dedup (esp32_ble_tracker parity) void BK72xxBLETracker::fire_scan_end_() {
#endif // Deliver held advertisements whose scan response never came (unmerged)
this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire // 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 } // namespace esphome::bk72xx_ble_tracker
@@ -21,6 +21,7 @@
// window: 30ms // window: 30ms
// duration: 5min // duration: 5min
// continuous: true // continuous: true
// active: true
#pragma once #pragma once
@@ -29,6 +30,7 @@
#include "esphome/components/bk72xx_ble/bk72xx_ble.h" #include "esphome/components/bk72xx_ble/bk72xx_ble.h"
#include "esphome/components/ble_device_base/ble_device.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/ble_hub.h"
#include "esphome/components/ble_device_base/scan_response_merger.h"
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
@@ -45,7 +47,6 @@ namespace esphome::bk72xx_ble_tracker {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component, class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener, public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE> public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER #ifdef USE_OTA_STATE_LISTENER
@@ -70,7 +71,28 @@ class BK72xxBLETracker : public Component,
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; } 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_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; } 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; } 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 ---- // ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan(). // Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
@@ -78,31 +100,30 @@ class BK72xxBLETracker : public Component,
void stop_scan(); void stop_scan();
// ---- ble_device_base::BLEHub contract ---- // ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override { void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT this->dispatcher_.register_listener(listener);
this->listeners_.push_back(listener);
#endif
} }
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override { void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
this->raw_advertisement_callback_ = callback; this->dispatcher_.set_raw_advertisement_callback(callback);
} }
ble_device_base::HubCapabilities get_capabilities() const override { static constexpr ble_device_base::HubCapabilities get_capabilities() {
// The Beken BDK exposes no active-scan path (passive scanning only), so the // Active scanning is driven through bk72xx_ble's reconciler because the BDK
// controller never solicits scan responses and never merges them; consumers // API itself is passive-only. The controller delivers scan responses as
// relying on scan-response fields (device names) get them only where the // separate reports; this tracker merges the pair before delivery (shared
// receiver merges per address (Home Assistant does). No GATT client either. // ScanResponseMerger, Bluedroid semantics). No GATT client.
return {.active_scan = false, .merges_scan_response = false, .gatt = false}; 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 // The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order. // wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override { void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
uint8_t mac[6]; uint8_t mac[MAC_ADDRESS_SIZE];
this->parent_->get_mac_lsb_first(mac); this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
out[i] = mac[5 - i]; out[i] = mac[5 - i];
} }
bool scan_running() override { return this->scan_running_; } bool scan_running() { return this->scan_running_; }
bool scan_active() override { return false; } // BK72xx scan is passive-only bool scan_active() { return this->scan_active_; }
// ---- bk72xx_ble::BLEScanListener ---- // ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the // Delivered by the controller's loop() on the ESPHome main task — the
@@ -112,37 +133,48 @@ class BK72xxBLETracker : public Component,
protected: protected:
void start_scan_(); void start_scan_();
void stop_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_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 // Defaults: the BK reference — 30 % duty cycle
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units. // (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_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_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
uint32_t scan_duration_{300000}; uint32_t scan_duration_{300000};
bool scan_continuous_{true}; 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 #ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure 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 #endif
uint32_t scan_start_time_{0}; uint32_t scan_start_time_{0};
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success) uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop())
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end() 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 bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
ble_device_base::RawAdvertisementCallback raw_advertisement_callback_{}; // Shared adv + scan-response merge and frame dispatch (ble_device_base).
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT // All calls run on the main task (the controller queue already crossed
// Parsed-advertisement consumers registered through ble_device_base. // tasks). Merger clock: stash_adv() reads the PARENT's cached loop time
// Codegen-sized: no heap allocation, no std::vector template instantiations. // (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this
StaticVector<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_; // component's — same App.loop() pass, so the delta stays non-negative and
#endif // the 300 ms timeout holds.
ble_device_base::ScanResponseMerger merger_;
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT ble_device_base::AdvDispatcher dispatcher_;
// Per-period "Found device" DEBUG log with MAC dedup — shared implementation
// in ble_device_base, identical output on every tracker backend. Guarded like
// its only writer so a no-listener build does not carry an unused vector.
ble_device_base::DiscoveredDeviceLog discovered_log_{};
#endif
}; };
} // namespace esphome::bk72xx_ble_tracker } // namespace esphome::bk72xx_ble_tracker
+36
View File
@@ -9,6 +9,7 @@ from esphome.const import (
CONF_ID, CONF_ID,
CONF_MAC_ADDRESS, CONF_MAC_ADDRESS,
CONF_NAME, CONF_NAME,
CONF_NOTIFY,
CONF_ON_CONNECT, CONF_ON_CONNECT,
CONF_ON_DISCONNECT, CONF_ON_DISCONNECT,
CONF_SERVICE_UUID, CONF_SERVICE_UUID,
@@ -16,11 +17,46 @@ from esphome.const import (
CONF_VALUE, CONF_VALUE,
) )
from esphome.core import ID from esphome.core import ID
from esphome.types import ConfigType
AUTO_LOAD = ["esp32_ble_client"] AUTO_LOAD = ["esp32_ble_client"]
CODEOWNERS = ["@buxtronix", "@clydebarrow"] CODEOWNERS = ["@buxtronix", "@clydebarrow"]
DEPENDENCIES = ["esp32_ble_tracker"] 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") ble_client_ns = cg.esphome_ns.namespace("ble_client")
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase) BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
BLEClientNode = ble_client_ns.class_("BLEClientNode") BLEClientNode = ble_client_ns.class_("BLEClientNode")
+3 -1
View File
@@ -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_) for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param); 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(); this->release_services();
ESP_LOGD(TAG, "All clients established, services released"); ESP_LOGD(TAG, "All clients established, services released");
} }
@@ -34,6 +34,11 @@ class BLEClientNode {
// This should be transitioned to Established once the node no longer needs // This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will // the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed. // 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; espbt::ClientState node_state;
BLEClient *parent() { return this->parent_; } BLEClient *parent() { return this->parent_; }
@@ -14,13 +14,16 @@ from esphome.const import (
UNIT_DECIBEL_MILLIWATT, 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"] DEPENDENCIES = ["ble_client"]
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
CONF_ON_NOTIFY = "on_notify"
TYPE_CHARACTERISTIC = "characteristic" TYPE_CHARACTERISTIC = "characteristic"
TYPE_RSSI = "rssi" TYPE_RSSI = "rssi"
@@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All(
}, },
lower=True, lower=True,
), ),
validate_descriptor_not_notify,
notify_from_on_notify,
) )
@@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
break; break;
} }
this->handle = chr->handle; 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_); auto *descr = chr->get_descriptor(this->descr_uuid_);
if (descr == nullptr) { if (descr == nullptr) {
this->status_set_warning(); 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; this->handle = descr->handle;
} }
if (this->notify_) { if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), auto status = this->parent()->register_for_notify(chr->handle);
this->parent()->get_remote_bda(), chr->handle);
if (status) { if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
} }
@@ -9,13 +9,16 @@ from esphome.const import (
CONF_TRIGGER_ID, 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"] 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 = cg.std_vector.template(cg.uint8)
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const") 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(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,
) )
@@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
break; break;
} }
this->handle = chr->handle; 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_); auto *descr = chr->get_descriptor(this->descr_uuid_);
if (descr == nullptr) { if (descr == nullptr) {
this->status_set_warning(); 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; this->handle = descr->handle;
} }
if (this->notify_) { if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(), auto status = this->parent()->register_for_notify(chr->handle);
this->parent()->get_remote_bda(), chr->handle);
if (status) { if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status); ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
} }
+240 -27
View File
@@ -3,57 +3,149 @@ ble_device_base — the platform-neutral BLE layer.
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData / Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract 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 BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
configuration declares via `cv.use_id(BLEHub)` ESPHome resolves any declared configuration declares via `cv.use_id(BLEHub)` ESPHome resolves any declared
subclass, so there is no platform table here and no dependency in either subclass, so there is no Python platform table here and no dependency in
direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and either direction (C++-side, the compile-time alias header ble_hub_impl.h and the
calls register_ble_device() in to_code; a tracker component subclasses BLEHub defines.h mirror are the deliberate exceptions). A sensor extends
(C++ and codegen class). Adding a new BLE chip requires only a new tracker BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a
component. 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 AES-CCM decryption for encrypted advertisements is provided portably in
ble_aes_ccm.h. ble_aes_ccm.h.
""" """
from collections.abc import Callable
import re import re
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components.const import CONF_WINDOW
import esphome.config_validation as cv 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
from esphome.types import ConfigType from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"] CODEOWNERS = ["@Bl00d-B0b"]
CONF_BLE_HUB_ID = "ble_hub_id" CONF_BLE_HUB_ID = "ble_hub_id"
# CORE.data key: number of parsed-advertisement listeners registered in this # Number of parsed-advertisement listeners registered in this build; read via
# build. Trackers whose codegen sizes storage at compile time (esp32's # cg.get_slot_count() by esp32_ble_tracker's feature coupling.
# StaticVector count define) read it in their final coroutine. LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count"
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base") ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
# The neutral tracker contract. Every tracker's codegen class declares this as a # The neutral tracker contract. Every tracker's codegen class declares this as
# parent, which is what lets cv.use_id(BLEHub) resolve any of them. # 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") BLEHub = ble_device_base_ns.class_("BLEHub")
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener). # The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener") ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
def inject_ble_hub(config: ConfigType) -> ConfigType: # Config keys that provide a BLEHub, registered by each tracker component at
"""Validator: auto-resolve the configured BLE tracker into the config. # 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 + # The in-tree trackers per target platform, so the missing-tracker error names
cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on # them even in a fresh process where no tracker module has been imported yet (a
any platform; multiple trackers can be disambiguated with an explicit # consumer imports only ble_device_base, so the registry is empty exactly in
ble_hub_id. # 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
return cv.Schema( # unknown/absent platform falls back to every in-tree name.
{cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA _IN_TREE_HUB_PROVIDERS: dict[str, str] = {
)(config) "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: def request_irk_support() -> None:
@@ -62,20 +154,119 @@ def request_irk_support() -> None:
cg.add_define("USE_BLE_DEVICE_IRK") cg.add_define("USE_BLE_DEVICE_IRK")
def get_listener_count() -> int: # Number of GATT client connection slots in this build; sizes the platform
"""Number of parsed listeners registered so far (for tracker codegen).""" # backend's connection storage.
return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) 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: async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
"""Register `var` as a parsed-advertisement listener on the configured hub.""" """Register `var` as a parsed-advertisement listener on the configured hub."""
hub = await cg.get_variable(config[CONF_BLE_HUB_ID]) hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
cg.add(hub.register_listener(var)) 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 return var
# ---- shared validation / codegen helpers (platform-neutral) ---- # ---- 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]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) 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 (("interval", interval), ("window", window)):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {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)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) 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
def scan_parameters_schema(
interval_default: str,
*,
window_default: str = "30ms",
) -> 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). 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.
"""
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,
}
return cv.All(cv.Schema(schema), validate_scan_parameters)
BT_UUID16_FORMAT = "XXXX" BT_UUID16_FORMAT = "XXXX"
BT_UUID32_FORMAT = "XXXXXXXX" BT_UUID32_FORMAT = "XXXXXXXX"
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
@@ -132,3 +323,25 @@ def as_hex_array(value: str) -> cg.RawExpression:
def as_reversed_hex_array(value: str) -> cg.RawExpression: def as_reversed_hex_array(value: str) -> cg.RawExpression:
return _hex_array_expression(value, reverse=True) 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}")
@@ -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 <algorithm>
#include <initializer_list>
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<const ESPBTDevice &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_addresses(std::initializer_list<uint64_t> 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<uint64_t> 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<const adv_data_t &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(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<const adv_data_t &>, public ESPBTDeviceListener {
public:
template<typename Hub> explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(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<typename Hub> 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
@@ -0,0 +1,128 @@
"""Shared codegen for the neutral BLE advertisement triggers (automation.h)."""
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
):
"""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):
"""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):
"""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()
@@ -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
@@ -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 <cstdint>
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
@@ -8,6 +8,7 @@
#include "ble_aes_ccm.h" #include "ble_aes_ccm.h"
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -98,6 +99,8 @@ ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
#ifdef USE_ESP32 #ifdef USE_ESP32
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) { 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) if (uuid.len == ESP_UUID_LEN_16)
return ESPBTUUID::from_uint16(uuid.uuid.uuid16); return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
if (uuid.len == ESP_UUID_LEN_32) 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 ESPBTUUID::get_uuid() const {
esp_bt_uuid_t ret; esp_bt_uuid_t ret;
switch (this->type_) { switch (this->type_) {
case Type::UNSET:
ret.len = 0;
memset(&ret.uuid, 0, sizeof(ret.uuid));
break;
case Type::UUID16: case Type::UUID16:
ret.len = ESP_UUID_LEN_16; ret.len = ESP_UUID_LEN_16;
ret.uuid.uuid16 = this->uuid_.uuid16; 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; this->scan_result_ = &scan_result;
// BLEScanResult's bda is most-significant octet first; the neutral ingest // BLEScanResult's bda is most-significant octet first; the neutral ingest
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
// address_str() then produce exactly the historical esp32 values. // address_str_to() then produce exactly the historical esp32 values.
uint8_t mac_lsb_first[6]; uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
for (uint8_t i = 0; i < 6; i++) for (uint8_t i = 0; i < 6; i++)
mac_lsb_first[i] = scan_result.bda[5 - 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, 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 #endif // USE_ESP32
ESPBTUUID ESPBTUUID::as_128bit() const { 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; return *this;
uint8_t data[16]; uint8_t data[16];
this->to_128bit_(data); this->to_128bit_(data);
@@ -149,6 +157,8 @@ ESPBTUUID ESPBTUUID::as_128bit() const {
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const { bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains. // Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
switch (this->type_) { switch (this->type_) {
case Type::UNSET:
return false;
case Type::UUID16: case Type::UUID16:
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1; return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
case Type::UUID32: case Type::UUID32:
@@ -173,6 +183,9 @@ const char *ESPBTUUID::to_str(char *buf) const {
// Identical output format to esp32_ble::ESPBTUUID::to_str. // Identical output format to esp32_ble::ESPBTUUID::to_str.
char *pos = buf; char *pos = buf;
switch (this->type_) { switch (this->type_) {
case Type::UNSET:
memcpy(buf, "None", 5);
return buf;
case Type::UUID16: case Type::UUID16:
*pos++ = '0'; *pos++ = '0';
*pos++ = 'x'; *pos++ = 'x';
@@ -207,6 +220,7 @@ const char *ESPBTUUID::to_str(char *buf) const {
void ESPBTUUID::to_128bit_(uint8_t out[16]) 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 // 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(). // 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, static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
if (this->type_ == Type::UUID128) { 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 { bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
if (this->type_ == other.type_) { if (this->type_ == other.type_) {
switch (this->type_) { switch (this->type_) {
case Type::UNSET:
return true;
case Type::UUID16: case Type::UUID16:
return this->uuid_.uuid16 == other.uuid_.uuid16; return this->uuid_.uuid16 == other.uuid_.uuid16;
case Type::UUID32: case Type::UUID32:
@@ -232,6 +248,9 @@ bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
} }
return false; 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 // 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). // configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
uint8_t a[16]; 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_)); } ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) { optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) {
// iBeacon manufacturer specific data (after company-ID bytes have been stripped): // 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 // [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 if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
return {}; return {};
if (data.data.size() != 23) if (data.data.size() != 23)
return {}; 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()); return ESPBLEiBeacon(data.data.data());
} }
@@ -264,6 +288,44 @@ optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData
// ESPBTDevice // ESPBTDevice
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
optional<ESPBLEiBeacon> 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 { const char *ESPBTDevice::address_type_str() const {
switch (this->address_type_) { switch (this->address_type_) {
case BLE_ADDR_TYPE_PUBLIC: case BLE_ADDR_TYPE_PUBLIC:
@@ -287,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_[i] = mac[5 - i];
this->address_type_ = addr_type; this->address_type_ = addr_type;
this->rssi_ = rssi; this->rssi_ = rssi;
this->name_.clear(); this->name_len_ = 0;
this->name_[0] = '\0';
this->service_uuids_.clear(); this->service_uuids_.clear();
this->manufacturer_datas_.clear(); this->manufacturer_datas_.clear();
this->service_datas_.clear(); this->service_datas_.clear();
@@ -303,7 +366,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
" Address: %s (%s)\n" " Address: %s (%s)\n"
" RSSI: %d\n" " RSSI: %d\n"
" Name: '%s'", " Name: '%s'",
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str()); this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_);
for (auto &it : this->tx_powers_) { for (auto &it : this->tx_powers_) {
ESP_LOGVV(TAG, " TX Power: %d", it); ESP_LOGVV(TAG, " TX Power: %d", it);
} }
@@ -346,6 +409,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE #endif // ESPHOME_LOG_HAS_VERY_VERBOSE
} }
// Remove before 2027.2.0
std::string ESPBTDevice::address_str() const { std::string ESPBTDevice::address_str() const {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->address_str_to(buf)); return std::string(this->address_str_to(buf));
@@ -414,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 // 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 // shortened and the complete name, and the shortened form must never replace the
// complete one (same rule as esp32_ble_tracker's parse_adv_). // complete one (same rule as esp32_ble_tracker's parse_adv_).
if (ad_data_len > this->name_.length()) if (ad_data_len > this->name_len_) {
this->name_.assign(reinterpret_cast<const char *>(ad_data), ad_data_len); uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast<uint8_t>(ad_data_len);
memcpy(this->name_, ad_data, name_len);
this->name_[name_len] = '\0';
this->name_len_ = name_len;
}
break; break;
case 0x0A: // TX Power Level case 0x0A: // TX Power Level
+41 -23
View File
@@ -14,6 +14,7 @@
#include "esphome/core/defines.h" #include "esphome/core/defines.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
@@ -85,14 +86,17 @@ class ESPBTUUID {
bool operator==(const ESPBTUUID &other) const; bool operator==(const ESPBTUUID &other) const;
bool operator!=(const ESPBTUUID &other) const { return !(*this == other); } bool operator!=(const ESPBTUUID &other) const { return !(*this == other); }
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf /// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form, or "None" for an
/// (>= UUID_STR_LEN bytes) and return buf. /// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf.
const char *to_str(char *buf) const; const char *to_str(char *buf) const;
#if defined(__cpp_lib_span) #if defined(__cpp_lib_span)
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); } const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
#endif #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_; } 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; } uint16_t uuid16() const { return this->uuid_.uuid16; }
uint32_t uuid32() const { return this->uuid_.uuid32; } uint32_t uuid32() const { return this->uuid_.uuid32; }
const uint8_t *uuid128() const { return this->uuid_.uuid128; } 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). // Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
void to_128bit_(uint8_t out[16]) const; void to_128bit_(uint8_t out[16]) const;
Type type_{Type::UUID16}; Type type_{Type::UNSET};
union { union {
uint16_t uuid16; uint16_t uuid16;
uint32_t uuid32; uint32_t uuid32;
@@ -126,7 +130,12 @@ class ESPBLEiBeacon {
public: public:
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); } ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
explicit ESPBLEiBeacon(const uint8_t *data); explicit ESPBLEiBeacon(const uint8_t *data);
static optional<ESPBLEiBeacon> 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<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr);
uint16_t get_major() const { return byteswap(this->beacon_data_.major); } uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); } uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
@@ -145,12 +154,9 @@ class ESPBLEiBeacon {
}; };
/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks. /// 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
/// The result is the printable-order value esp32 has always sent /// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent
/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire. /// for an already parsed device, whose address is stored MSB-first.
/// This takes the raw controller order delivered by BLEHub's raw-advertisement
/// callback; 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) { inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
uint64_t addr = 0; uint64_t addr = 0;
for (int i = 0; i < 6; i++) for (int i = 0; i < 6; i++)
@@ -158,6 +164,13 @@ inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
return addr; 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 // ESPBTDevice — parsed BLE advertisement
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -172,8 +185,9 @@ class ESPBTDevice {
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE; 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. /// 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; 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; const char *address_str_to(char *buf) const;
#if defined(__cpp_lib_span) #if defined(__cpp_lib_span)
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const { const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
@@ -189,6 +203,8 @@ class ESPBTDevice {
// Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t. // 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<esp_ble_addr_type_t>(this->address_type_); } esp_ble_addr_type_t get_address_type() const { return static_cast<esp_ble_addr_type_t>(this->address_type_); }
/// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result. /// 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); void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
// Exposed through a function for use in lambdas // Exposed through a function for use in lambdas
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; } const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
@@ -200,7 +216,9 @@ class ESPBTDevice {
const char *address_type_str() const; const char *address_type_str() const;
int get_rssi() const { return rssi_; } 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<ESPBTUUID> &get_service_uuids() const { return service_uuids_; } const std::vector<ESPBTUUID> &get_service_uuids() const { return service_uuids_; }
const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; } const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; }
@@ -214,22 +232,22 @@ class ESPBTDevice {
/// decryptor; compiled only when a sensor configures irk: (request_irk_support). /// decryptor; compiled only when a sensor configures irk: (request_irk_support).
bool resolve_irk(const uint8_t *irk) const; bool resolve_irk(const uint8_t *irk) const;
optional<ESPBLEiBeacon> get_ibeacon() const { optional<ESPBLEiBeacon> get_ibeacon() const;
for (const auto &it : this->manufacturer_datas_) {
auto res = ESPBLEiBeacon::from_manufacturer_data(it);
if (res.has_value())
return res;
}
return {};
}
protected: protected:
void parse_adv_(const uint8_t *payload, uint16_t len); 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}; uint8_t address_type_{0};
int rssi_{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<ESPBTUUID> service_uuids_{}; std::vector<ESPBTUUID> service_uuids_{};
std::vector<ServiceData> manufacturer_datas_{}; std::vector<ServiceData> manufacturer_datas_{};
std::vector<ServiceData> service_datas_{}; std::vector<ServiceData> service_datas_{};
@@ -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 <concepts>
#include <cstdint>
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<typename T>
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.gatt_disconnect() } -> std::same_as<int>;
{ conn.cancel_gatt_disconnect() } -> std::same_as<bool>;
{ conn.discover_services() } -> std::same_as<int>;
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
{ conn.read_descriptor(uint16_t{}) } -> std::same_as<int>;
{ conn.write_descriptor(uint16_t{}, data, uint16_t{}) } -> std::same_as<int>;
{ conn.notify_characteristic(uint16_t{}, true) } -> std::same_as<int>;
{ conn.pair() } -> std::same_as<int>;
{ conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as<int>;
{ conn.get_service_table() } -> std::same_as<GattServiceTable>;
{ conn.release_services() } -> std::same_as<void>;
// Connection-type hint for backends that tune parameters by it; others
// carry an inline no-op.
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
};
} // namespace esphome::ble_device_base
#endif // USE_BLE_GATT_CLIENT
+68 -30
View File
@@ -1,13 +1,10 @@
// ble_hub.h // ble_hub.h
// //
// BLEHub — the platform-neutral BLE tracker contract. // The platform-neutral BLE tracker contract: shared types plus the method
// // surface every tracker provides (documented below). Exactly one tracker
// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker, // exists per build, so BLEHub is a compile-time alias (ble_hub_impl.h), not
// ln882h_ble_tracker, future chips) implements this interface; every BLE // an abstract interface — no vtable, every hub call inlinable. Consumers
// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via // include ble_hub_impl.h and bind in YAML via cv.use_id(BLEHub).
// `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.
// //
// Chip differences are expressed as data (HubCapabilities), never as // Chip differences are expressed as data (HubCapabilities), never as
// platform conditionals in consumers. // platform conditionals in consumers.
@@ -15,7 +12,9 @@
#pragma once #pragma once
#include "ble_device.h" #include "ble_device.h"
#include "esphome/core/defines.h"
#include <concepts>
#include <cstdint> #include <cstdint>
namespace esphome::ble_device_base { namespace esphome::ble_device_base {
@@ -23,8 +22,9 @@ namespace esphome::ble_device_base {
/// One raw advertisement as delivered by the controller — a borrowed view, /// One raw advertisement as delivered by the controller — a borrowed view,
/// valid only for the duration of the invoke() callback. /// valid only for the duration of the invoke() callback.
struct RawAdvertisement { struct RawAdvertisement {
/// Least-significant octet first (BLE controller convention). /// Producers convert their native byte order at the emit site, so no
const uint8_t *mac; /// byte-order convention crosses this contract.
uint64_t address;
const uint8_t *data; const uint8_t *data;
uint16_t data_len; uint16_t data_len;
int8_t rssi; // signed dBm int8_t rssi; // signed dBm
@@ -48,6 +48,27 @@ struct RawAdvertisementCallback {
void invoke(const RawAdvertisement &adv) const { this->fn(this->instance, adv); } 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. /// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
struct HubCapabilities { struct HubCapabilities {
/// Controller can send scan requests (active scanning). /// Controller can send scan requests (active scanning).
@@ -56,29 +77,46 @@ struct HubCapabilities {
/// frame. When false, consumers relying on scan-response fields (e.g. names) /// 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). /// may only see them where the receiver merges per address (Home Assistant does).
bool merges_scan_response; bool merges_scan_response;
/// GATT client connections are available (today: esp32 only, but a chip SDK /// GATT client connections are available: the platform has a
/// gaining GATT support only has to flip this bit). /// 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; 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 { // The BLEHub method surface, asserted where ble_hub_impl.h binds the alias.
public: // Semantics beyond the signatures:
virtual ~BLEHub() = default; // - register_listener: parsed-advertisement consumers (sensors, triggers).
// - set_raw_advertisement_callback: raw stream, one consumer at a time.
/// Register a parsed-advertisement consumer (BLE sensors, automation triggers). // - get_adapter_mac: printable order, out[0] = MSB.
virtual void register_listener(ESPBTDeviceListener *listener) = 0; // - scan_active: the current/configured mode sends scan requests.
// - request_scan_mode: false = cannot honor, state untouched (the caller
/// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time. // reports the real state back); true = applied immediately, restarting a
virtual void set_raw_advertisement_callback(RawAdvertisementCallback callback) = 0; // running scan. Honoring is advertised by HubCapabilities::scan_mode_switch.
// Push hubs additionally provide set_scanner_state_callback(ScannerStateCallback)
virtual HubCapabilities get_capabilities() const = 0; // 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
/// Adapter MAC in printable (MSB-first) order, out[0] = MSB. // transition for every accepted or refused mode request - consumers skip
virtual void get_adapter_mac(uint8_t out[6]) = 0; // their own mode report on push builds.
template<typename T>
virtual bool scan_running() = 0; concept BLEHubContract = requires(T hub, ESPBTDeviceListener *listener, RawAdvertisementCallback raw_callback,
/// True when the current/configured scan mode is active (scan requests sent). uint8_t *mac) {
virtual bool scan_active() = 0; hub.register_listener(listener);
hub.set_raw_advertisement_callback(raw_callback);
{ T::get_capabilities() } -> std::same_as<HubCapabilities>;
hub.get_adapter_mac(mac);
{ hub.scan_running() } -> std::same_as<bool>;
{ hub.scan_active() } -> std::same_as<bool>;
{ hub.request_scan_mode(true) } -> std::same_as<bool>;
#ifdef USE_BLE_SCANNER_STATE_CALLBACK
hub.set_scanner_state_callback(ScannerStateCallback{});
{ hub.get_scanner_state() } -> std::same_as<ScannerState>;
#endif
}; };
} // namespace esphome::ble_device_base } // namespace esphome::ble_device_base
@@ -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<BLEHub>, "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
@@ -0,0 +1,153 @@
#include "scan_response_merger.h"
#ifdef USE_BLE_SCAN_RESPONSE_MERGER
#include "esphome/core/helpers.h"
#include <cstring>
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
@@ -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 <cstdint>
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<ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> 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
@@ -1,5 +1,5 @@
import esphome.codegen as cg 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 import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_IBEACON_MAJOR, CONF_IBEACON_MAJOR,
@@ -13,14 +13,14 @@ from esphome.const import (
CONF_IRK = "irk" CONF_IRK = "irk"
DEPENDENCIES = ["esp32_ble_tracker"] AUTO_LOAD = ["ble_device_base"]
ble_presence_ns = cg.esphome_ns.namespace("ble_presence") ble_presence_ns = cg.esphome_ns.namespace("ble_presence")
BLEPresenceDevice = ble_presence_ns.class_( BLEPresenceDevice = ble_presence_ns.class_(
"BLEPresenceDevice", "BLEPresenceDevice",
binary_sensor.BinarySensor, binary_sensor.BinarySensor,
cg.Component, cg.Component,
esp32_ble_tracker.ESPBTDeviceListener, ble_device_base.ESPBTDeviceListener,
) )
@@ -33,23 +33,24 @@ def _validate(config):
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_presence"),
binary_sensor.binary_sensor_schema(BLEPresenceDevice) binary_sensor.binary_sensor_schema(BLEPresenceDevice)
.extend( .extend(
{ {
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_IRK): cv.uuid, 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_MAJOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_MINOR): 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_TIMEOUT, default="5min"): cv.positive_time_period,
cv.Optional(CONF_MIN_RSSI): cv.All( cv.Optional(CONF_MIN_RSSI): cv.All(
cv.decibel, cv.int_range(min=-100, max=-30) 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( cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
), ),
@@ -60,7 +61,7 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config): async def to_code(config):
var = await binary_sensor.new_binary_sensor(config) var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, 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)) cg.add(var.set_timeout(config[CONF_TIMEOUT].total_milliseconds))
if min_rssi := config.get(CONF_MIN_RSSI): if min_rssi := config.get(CONF_MIN_RSSI):
@@ -70,20 +71,15 @@ async def to_code(config):
cg.add(var.set_address(mac_address.as_hex)) cg.add(var.set_address(mac_address.as_hex))
if irk := config.get(CONF_IRK): 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)) cg.add(var.set_irk(irk))
if service_uuid := config.get(CONF_SERVICE_UUID): if service_uuid := config.get(CONF_SERVICE_UUID):
if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): ble_device_base.add_service_uuid(var, service_uuid)
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))
if ibeacon_uuid := config.get(CONF_IBEACON_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)) cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
@@ -1,8 +1,6 @@
#include "ble_presence_device.h" #include "ble_presence_device.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_presence { namespace esphome::ble_presence {
static const char *const TAG = "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); } void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); }
} // namespace esphome::ble_presence } // namespace esphome::ble_presence
#endif
@@ -1,15 +1,17 @@
#pragma once #pragma once
#include "esphome/core/component.h" #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" #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 { namespace esphome::ble_presence {
class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff, class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
public esp32_ble_tracker::ESPBTDeviceListener, public ble_device_base::ESPBTDeviceListener,
public Component { public Component {
public: public:
void set_address(uint64_t address) { void set_address(uint64_t address) {
@@ -22,19 +24,19 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
} }
void set_service_uuid16(uint16_t uuid) { void set_service_uuid16(uint16_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_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) { void set_service_uuid32(uint32_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_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) { void set_service_uuid128(uint8_t *uuid) {
this->match_by_ = MATCH_BY_SERVICE_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) { void set_ibeacon_uuid(uint8_t *uuid) {
this->match_by_ = MATCH_BY_IBEACON_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) { void set_ibeacon_major(uint16_t major) {
this->check_ibeacon_major_ = true; this->check_ibeacon_major_ = true;
@@ -49,7 +51,7 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
this->minimum_rssi_ = rssi; this->minimum_rssi_ = rssi;
} }
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } 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()) { if (this->check_minimum_rssi_ && this->minimum_rssi_ > device.get_rssi()) {
return false; return false;
} }
@@ -119,9 +121,9 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
uint64_t address_; uint64_t address_;
uint8_t *irk_; 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_major_{0};
uint16_t ibeacon_minor_{0}; uint16_t ibeacon_minor_{0};
@@ -137,5 +139,3 @@ class BLEPresenceDevice final : public binary_sensor::BinarySensorInitiallyOff,
}; };
} // namespace esphome::ble_presence } // namespace esphome::ble_presence
#endif
@@ -1,8 +1,6 @@
#include "ble_rssi_sensor.h" #include "ble_rssi_sensor.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_rssi { namespace esphome::ble_rssi {
static const char *const TAG = "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); } void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); }
} // namespace esphome::ble_rssi } // namespace esphome::ble_rssi
#endif
+13 -13
View File
@@ -1,14 +1,16 @@
#pragma once #pragma once
#include "esphome/core/component.h" #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" #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 { 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: public:
void set_address(uint64_t address) { void set_address(uint64_t address) {
this->match_by_ = MATCH_BY_MAC_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) { void set_service_uuid16(uint16_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_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) { void set_service_uuid32(uint32_t uuid) {
this->match_by_ = MATCH_BY_SERVICE_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) { void set_service_uuid128(uint8_t *uuid) {
this->match_by_ = MATCH_BY_SERVICE_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) { void set_ibeacon_uuid(uint8_t *uuid) {
this->match_by_ = MATCH_BY_IBEACON_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) { void set_ibeacon_major(uint16_t major) {
this->check_ibeacon_major_ = true; 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->publish_state(NAN);
this->found_ = false; 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_) { switch (this->match_by_) {
case MATCH_BY_MAC_ADDRESS: case MATCH_BY_MAC_ADDRESS:
if (device.address_uint64() == this->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_; uint64_t address_;
uint8_t *irk_; 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_major_;
uint16_t ibeacon_minor_; uint16_t ibeacon_minor_;
@@ -120,5 +122,3 @@ class BLERSSISensor final : public sensor::Sensor, public esp32_ble_tracker::ESP
}; };
} // namespace esphome::ble_rssi } // namespace esphome::ble_rssi
#endif
+13 -17
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg 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 import esphome.config_validation as cv
from esphome.const import ( from esphome.const import (
CONF_IBEACON_MAJOR, CONF_IBEACON_MAJOR,
@@ -14,11 +14,11 @@ from esphome.const import (
CONF_IRK = "irk" CONF_IRK = "irk"
DEPENDENCIES = ["esp32_ble_tracker"] AUTO_LOAD = ["ble_device_base"]
ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi") ble_rssi_ns = cg.esphome_ns.namespace("ble_rssi")
BLERSSISensor = ble_rssi_ns.class_( BLERSSISensor = ble_rssi_ns.class_(
"BLERSSISensor", sensor.Sensor, cg.Component, esp32_ble_tracker.ESPBTDeviceListener "BLERSSISensor", sensor.Sensor, cg.Component, ble_device_base.ESPBTDeviceListener
) )
@@ -31,6 +31,7 @@ def _validate(config):
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_rssi"),
sensor.sensor_schema( sensor.sensor_schema(
BLERSSISensor, BLERSSISensor,
unit_of_measurement=UNIT_DECIBEL_MILLIWATT, unit_of_measurement=UNIT_DECIBEL_MILLIWATT,
@@ -42,14 +43,14 @@ CONFIG_SCHEMA = cv.All(
{ {
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_IRK): cv.uuid, 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_MAJOR): cv.uint16_t,
cv.Optional(CONF_IBEACON_MINOR): 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( cv.has_exactly_one_key(
CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID CONF_MAC_ADDRESS, CONF_IRK, CONF_SERVICE_UUID, CONF_IBEACON_UUID
), ),
@@ -60,26 +61,21 @@ CONFIG_SCHEMA = cv.All(
async def to_code(config): async def to_code(config):
var = await sensor.new_sensor(config) var = await sensor.new_sensor(config)
await cg.register_component(var, 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): if mac_address := config.get(CONF_MAC_ADDRESS):
cg.add(var.set_address(mac_address.as_hex)) cg.add(var.set_address(mac_address.as_hex))
if irk := config.get(CONF_IRK): 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)) cg.add(var.set_irk(irk))
if service_uuid := config.get(CONF_SERVICE_UUID): if service_uuid := config.get(CONF_SERVICE_UUID):
if len(service_uuid) == len(esp32_ble_tracker.bt_uuid16_format): ble_device_base.add_service_uuid(var, service_uuid)
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))
if ibeacon_uuid := config.get(CONF_IBEACON_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)) cg.add(var.set_ibeacon_uuid(ibeacon_uuid))
if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None: if (ibeacon_major := config.get(CONF_IBEACON_MAJOR)) is not None:
@@ -1,8 +1,6 @@
#include "ble_scanner.h" #include "ble_scanner.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#ifdef USE_ESP32
namespace esphome::ble_scanner { namespace esphome::ble_scanner {
static const char *const TAG = "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); } void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); }
} // namespace esphome::ble_scanner } // namespace esphome::ble_scanner
#endif
+8 -10
View File
@@ -7,23 +7,23 @@
#include "esphome/core/component.h" #include "esphome/core/component.h"
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h" #include "esphome/core/string_ref.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/text_sensor/text_sensor.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 { namespace esphome::ble_scanner {
class BLEScanner final : public text_sensor::TextSensor, class BLEScanner final : public text_sensor::TextSensor, public ble_device_base::ESPBTDeviceListener, public Component {
public esp32_ble_tracker::ESPBTDeviceListener,
public Component {
public: 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]; char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
// Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this
// sensor has always published. // sensor has always published.
char escaped_name[128]; char escaped_name[128];
json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false); json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false);
char buf[256]; char buf[256];
snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}",
@@ -35,5 +35,3 @@ class BLEScanner final : public text_sensor::TextSensor,
}; };
} // namespace esphome::ble_scanner } // namespace esphome::ble_scanner
#endif
@@ -1,25 +1,26 @@
import esphome.codegen as cg 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 import esphome.config_validation as cv
DEPENDENCIES = ["esp32_ble_tracker"] AUTO_LOAD = ["ble_device_base"]
ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner") ble_scanner_ns = cg.esphome_ns.namespace("ble_scanner")
BLEScanner = ble_scanner_ns.class_( BLEScanner = ble_scanner_ns.class_(
"BLEScanner", "BLEScanner",
text_sensor.TextSensor, text_sensor.TextSensor,
cg.Component, cg.Component,
esp32_ble_tracker.ESPBTDeviceListener, ble_device_base.ESPBTDeviceListener,
) )
CONFIG_SCHEMA = cv.All( CONFIG_SCHEMA = cv.All(
ble_device_base.rename_legacy_hub_id("ble_scanner"),
text_sensor.text_sensor_schema(BLEScanner) text_sensor.text_sensor_schema(BLEScanner)
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA) .extend(cv.COMPONENT_SCHEMA)
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
) )
async def to_code(config): async def to_code(config):
var = await text_sensor.new_text_sensor(config) var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, 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)
@@ -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)
@@ -0,0 +1,68 @@
#include "bluetooth_connection.h"
#ifdef USE_ESP32
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
#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 &current_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
@@ -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 <array>
#include <cstddef>
#include <cstdint>
#ifdef USE_ESP32
#include <esp_err.h>
#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<uint64_t, 2> &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<uint64_t, 2> &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 &current_size, int16_t &send_service,
uint8_t connection_index, const char *address_str);
#endif // USE_BLUETOOTH_PROXY_CONNECTIONS
} // namespace esphome::bluetooth_connection
@@ -0,0 +1,772 @@
#include "bluetooth_connection_bluedroid.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
// The in-place streamer serves the proxy's service-discovery API; backend-only
// builds compile without the proxy headers or the streamer.
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
#include "bluetooth_connection.h"
#include "bluetooth_connection_hub.h"
#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h"
#endif
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cstring>
namespace esphome::bluetooth_connection {
static const char *const TAG = "bluetooth_connection.bluedroid";
using ble_device_base::FAST_CONN_TIMEOUT;
using ble_device_base::FAST_MAX_CONN_INTERVAL;
using ble_device_base::FAST_MIN_CONN_INTERVAL;
using ble_device_base::MEDIUM_CONN_TIMEOUT;
using ble_device_base::MEDIUM_MAX_CONN_INTERVAL;
using ble_device_base::MEDIUM_MIN_CONN_INTERVAL;
using esp32_ble_tracker::ClientState;
using esp32_ble_tracker::ConnectionType;
// ---- tracker surface ----
void BluedroidGattClient::connect() { this->tracker_connect_(); }
void BluedroidGattClient::disconnect() { this->gatt_disconnect(); }
// ---- component ----
void BluedroidGattClient::setup() {
static uint8_t connection_index = 0;
this->connection_index_ = connection_index++;
}
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: no CLOSE_EVT will come. Settle a live link so the consumer
// frees its slot, then re-register the app on the next enable.
auto down_st = this->state();
if (down_st != ClientState::IDLE && down_st != ClientState::INIT) {
this->release_services();
this->set_idle_();
this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED);
}
this->set_state(ClientState::INIT);
return;
}
auto st = this->state();
if (st == ClientState::INIT) {
// Parity with BLEClientBase: a failed registration marks the slot
// failed and idles it without retry.
auto ret = esp_ble_gattc_app_register(this->app_id);
if (ret) {
ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret);
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
this->set_idle_();
} else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) {
// The one teardown safety net: a lost CLOSE_EVT, or a scheduled
// teardown whose OPEN_EVT never arrives.
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_);
// Release before idling: a lost completion must not leak the cache.
this->release_services();
this->set_idle_(); // also clears want_disconnect_
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT);
}
} else {
// The loop stays on while a link exists (stack-down watch, pre-started
// search flush); it settles only back at IDLE.
this->deliver_pending_search_();
if (this->state() == ClientState::IDLE) {
this->disable_loop();
}
}
}
void BluedroidGattClient::dump_config() {
ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_);
if (this->is_failed()) {
ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots");
}
}
// ---- contract ops ----
int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
// Only from idle: clobbering DISCONNECTING would open a new link the
// stale CLOSE_EVT then tears down.
if (this->state() != ClientState::IDLE) {
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
return ESP_GATT_BUSY;
}
ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_);
this->remote_addr_type_ = addr_type;
// Hand the request to the tracker's promote loop: it stops the scan, raises
// coex, and calls tracker_connect_() - the tracker owns connect timing here.
this->set_state(ClientState::DISCOVERED);
return 0;
}
void BluedroidGattClient::tracker_connect_() {
auto st = this->state();
if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) {
ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_);
return;
}
if (st == ClientState::DISCONNECTING) {
ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_);
return;
}
ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_);
// Per-attempt latches; the search machine is reset by set_idle_(), the
// one door back to IDLE.
this->services_released_ = false;
this->seen_mtu_ = false;
this->mtu_failed_ = false;
this->enable_loop();
this->set_state(ClientState::CONNECTING);
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
// Fast params for the discovery phase; stepped down at SEARCH_CMPL.
this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params",
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL,
FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT));
} else {
this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params",
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL,
MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT));
}
auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_,
static_cast<esp_ble_addr_type_t>(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<uint8_t *>(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<uint8_t *>(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<esp_gatt_status_t>(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
@@ -0,0 +1,142 @@
// Bluedroid (esp32) GATT client backend: the esp32 arm of the
// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection
// wrapper. Not a BLEClientBase: the tracker's promote loop owns
// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only
// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in
// the tracker-invoked connect() override.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT)
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
namespace esphome::bluetooth_connection {
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothConnection;
#endif
// One class carries both halves: the tracker's ESPBTClient surface (its
// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the
// virtual connect()/disconnect()) and the neutral contract ops. The
// contract's teardown op is named gatt_disconnect() because the tracker's
// void disconnect() cannot overload with an int-returning twin.
class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component {
public:
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
// Lifecycle of one connection attempt's service search.
enum class SearchState : uint8_t {
NONE, // no search this attempt
PRESTARTED, // issued at OPEN_EVT, no claimant yet
PRESTART_DONE, // completed with search_status_ latched, no claimant yet
CLAIMED, // in flight with a claimant (pre-started or direct)
REPORT_PENDING // completed and claimed: deliver on the next flush
};
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Wired by codegen before setup and invariant for the device lifetime.
void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; }
// ---- esp32_ble_tracker::ESPBTClient ----
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
void connect() override;
void disconnect() override;
bool wants_parsed_advertisements() override { return false; }
void on_scan_end() override {}
bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; }
// ---- ble_device_base::BLEGattConnection contract ----
int connect(uint64_t address, uint8_t addr_type);
int gatt_disconnect();
bool cancel_gatt_disconnect();
int discover_services();
int read_characteristic(uint16_t handle);
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response);
int read_descriptor(uint16_t handle);
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len);
int notify_characteristic(uint16_t handle, bool enable);
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// Contract stub: the proxy streams in place; the on-demand materializer
// for direct consumers lands with #18205. NOTE: a direct consumer reaching
// this stub gets an empty table indistinguishable from a service-less
// peer - do not ship one against this backend before the materializer.
ble_device_base::GattServiceTable get_service_table() { return {}; }
void release_services();
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
/// In-place service streamer (the proxy wrapper detects and prefers it):
/// builds one api response batch directly from Bluedroid's cached database,
/// so the streaming peak is the response itself - the old esp32 model.
void stream_service_batch(BluetoothConnection &conn);
#endif
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
protected:
bool check_addr_(const esp_bd_addr_t &addr) const;
void tracker_connect_();
void handle_open_evt_(esp_ble_gattc_cb_param_t *param);
void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param);
int handle_search_cmpl_(esp_gatt_status_t status);
void deliver_pending_search_();
void unconditional_disconnect_();
void set_idle_();
void set_disconnecting_();
esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout,
const char *param_type);
int check_and_log_error_(const char *operation, esp_err_t err);
void log_gattc_warning_(const char *operation, int code);
// Group 1: pointers / composed objects
ble_device_base::GattClientListener *listener_{nullptr};
// Group 2: 4-byte types
uint32_t disconnecting_started_{0};
// Group 3: arrays
esp_bd_addr_t remote_bda_{};
// Group 4: 2-byte types
uint16_t conn_id_{UNSET_CONN_ID};
uint16_t service_total_{0};
// Group 5: 1-byte types
esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
uint8_t remote_addr_type_{0};
esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE};
uint8_t connection_index_{0};
// Terminates an in-flight stream (never send a partial list as authoritative)
// and marks a cleaned cache unsafe to walk (Bluedroid asserts).
bool services_released_ : 1 {false};
// The connected report waits for the MTU exchange; OPEN_EVT alone would
// hand HA the default 23.
bool seen_mtu_ : 1 {false};
// The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead.
bool mtu_failed_ : 1 {false};
// Search issued at OPEN_EVT overlaps the MTU exchange; discover_services()
// completes from it. Reset by set_idle_().
static_assert(static_cast<uint8_t>(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
@@ -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<BLEGattConnection>,
"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
@@ -0,0 +1,567 @@
// 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<typename Response>
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::BluetoothGATTWriteResponse>(api_connection, this->address_, handle);
case PendingAck::PENDING_ACK_NOTIFY:
return send_handle_reply<api::BluetoothGATTNotifyResponse>(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.
if (!this->has_pending_ack_()) {
ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_,
this->address_str_, handle);
} 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.
ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_);
}
}
// ---- 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<uint16_t>(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<uint16_t>(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
@@ -0,0 +1,274 @@
// 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;
}
/// 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<typename Backend> 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. Four bitfield
// bits left; another byte-sized member costs 8 per slot.
static_assert(static_cast<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
static_assert(static_cast<uint8_t>(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<uint8_t>(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};
// 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
File diff suppressed because it is too large Load Diff
@@ -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 <btstack.h>
#include <array>
#include <cstdint>
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<rp2040_ble::RP2040BLE>
#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<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
esphome::LockFreeQueue<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE> notify_queue_;
esphome::EventPool<RP2GattNotifyEvent, RP2_GATT_NOTIFY_QUEUE_SIZE - 1> 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<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> 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
+356 -56
View File
@@ -1,14 +1,62 @@
import functools
import logging import logging
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker from esphome.components import ble_device_base, bluetooth_connection
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.esp32_ble import BTLoggers
import esphome.config_validation as cv 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"] # The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily
DEPENDENCIES = ["api", "esp32"] # 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"] CODEOWNERS = ["@jesserockz", "@bdraco"]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -20,65 +68,302 @@ DEFAULT_CONNECTION_SLOTS = 3
bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
BluetoothProxy = bluetooth_proxy_ns.class_( BluetoothProxy = bluetooth_proxy_ns.class_("BluetoothProxy", cg.Component)
"BluetoothProxy", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
)
BluetoothConnection = bluetooth_proxy_ns.class_(
"BluetoothConnection", esp32_ble_client.BLEClientBase
)
CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( # Mirrors esp32_ble.IDF_MAX_CONNECTIONS (the loosest platform cap): the esp32
{ # schema builder asserts the two agree, tests/component_tests/bluetooth_proxy/
cv.GenerateID(): cv.declare_id(BluetoothConnection), # pins them together, and the outer walkable schema uses it as the
} # connection_slots bound (per-platform schemas tighten it).
).extend(cv.COMPONENT_SCHEMA) _IDF_MAX_CONNECTIONS = 9
def validate_connections(config): @functools.cache
if CONF_CONNECTIONS in config: def _esp32_config_schema() -> cv.All:
if not config[CONF_ACTIVE]: """Build the esp32 schema, importing the esp32 BLE stack only when used."""
raise cv.Invalid( from esphome.components import esp32_ble, esp32_ble_tracker
"Connections can only be used if the proxy is set to active"
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):
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 { return {
**config, **config,
CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)], 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 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.Schema(
{ {
cv.GenerateID(): cv.declare_id(BluetoothProxy), **_COMMON_SCHEMA_KEYS,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
cv.Optional(CONF_CACHE_SERVICES, default=True): cv.boolean,
cv.Optional( cv.Optional(
CONF_CONNECTION_SLOTS, CONF_CONNECTION_SLOTS,
default=DEFAULT_CONNECTION_SLOTS, default=min(DEFAULT_CONNECTION_SLOTS, max_conn),
): cv.All( ): cv.All(
cv.positive_int, cv.positive_int,
cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), cv.Range(
), min=1,
cv.Optional(CONF_CONNECTIONS): cv.All( max=max_conn,
cv.ensure_list(CONNECTION_SCHEMA), msg=f"rp2 supports at most {max_conn} connection slot(s); "
cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), "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) .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 # Register the loggers this component needs
esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP) esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.L2CAP, BTLoggers.SMP)
@@ -86,11 +371,35 @@ async def to_code(config):
await cg.register_component(var, config) await cg.register_component(var, config)
cg.add(var.set_active(config[CONF_ACTIVE])) 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 # Compiles the scanner-state push slot into the tracker and the matching
connection_count = len(config.get(CONF_CONNECTIONS, [])) # registration into the proxy; the other hubs are polled instead.
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK")
await _connections_to_code(var, config)
if config.get(CONF_CACHE_SERVICES):
add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True)
async def _to_code_ble_hub(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_active(config[CONF_ACTIVE]))
hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID])
cg.add(var.set_ble_hub(hub))
await _connections_to_code(var, config)
async def to_code(config: ConfigType) -> None:
if CORE.is_esp32:
await _to_code_esp32(config)
else:
await _to_code_ble_hub(config)
# Define batch size for BLE advertisements # Define batch size for BLE advertisements
# Each advertisement is up to 80 bytes when packaged (including protocol overhead) # Each advertisement is up to 80 bytes when packaged (including protocol overhead)
@@ -98,13 +407,4 @@ async def to_code(config):
# This achieves ~97% WiFi MTU utilization while staying under the limit # This achieves ~97% WiFi MTU utilization while staying under the limit
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) 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") cg.add_define("USE_BLUETOOTH_PROXY")
@@ -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<uint64_t, 2> &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<uint64_t, 2> &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<uint8_t *>(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<uint8_t *>(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

Some files were not shown because too many files have changed in this diff Show More