Merge remote-tracking branch 'origin/dev' into feat/nrf52_pwm

This commit is contained in:
Tomasz Duda
2026-08-02 16:00:00 +02:00
1098 changed files with 45588 additions and 14294 deletions
+2
View File
@@ -1,3 +1,5 @@
# Normalize line endings to LF in the repository
* text eol=lf
*.png binary
*.gif binary
*.apng binary
+5
View File
@@ -6,6 +6,7 @@
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
@@ -20,6 +21,10 @@
- esphome/esphome.io#<esphome.io PR number goes here>
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
## Test Environment
- [ ] ESP32
+4 -2
View File
@@ -3,8 +3,10 @@ description: >
Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
(toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
natively (clang-tidy for IDF/Arduino and the component test batches) shares
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", so all toolchains are present regardless of the chip).
one cache, since the install is identical: ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", and _get_configured_targets() in espidf/toolchain.py
skips per-variant narrowing whenever CI is set, so all toolchains are
present regardless of the chip a job builds.
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
Python venv already restored.
inputs:
+49
View File
@@ -0,0 +1,49 @@
name: Cache nRF Connect SDK
description: >
Resolve the pinned sdk-nrf version and cache the native sdk-nrf install
(west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf.
Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and,
once the component tests build natively, their batches) shares one cache.
Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have
the Python venv already restored.
inputs:
restore-only:
description: >
When "true", only restore -- never save the cache, even on dev. Use from
jobs that may not produce a complete install (e.g. a component batch
that fails mid-install), so a partial install is never written.
default: "false"
runs:
using: composite
steps:
- name: Resolve sdk-nrf and toolchain versions for cache key
# Both versions are pinned in code, not in any file that feeds the
# other cache keys, so resolve them explicitly. Keying on them means
# the cache invalidates when either is bumped (actions/cache never
# overwrites a key).
id: version
shell: bash
run: |
. venv/bin/activate
version=$(python -c '
from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION
from esphome.components.nrf52.framework import TOOLCHAIN_VERSION
print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")')
echo "version=$version" >> "$GITHUB_OUTPUT"
# Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it
# lives in the default-branch scope readable by all PRs); PRs are
# restore-only and never push multi-GB artifacts into their own scope.
- name: Cache nRF Connect SDK install (write on dev)
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.esphome-sdk-nrf
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
- name: Cache nRF Connect SDK install (restore-only off dev)
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.esphome-sdk-nrf
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
+5 -2
View File
@@ -17,7 +17,7 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
@@ -32,9 +32,12 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@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.
@@ -13,6 +13,7 @@ module.exports = {
'merging-to-release',
'merging-to-beta',
'chained-pr',
'stacked-pr',
'core',
'small-pr',
'medium-pr',
@@ -22,11 +23,13 @@ module.exports = {
'has-tests',
'needs-tests',
'needs-docs',
'needs-developer-docs',
'needs-codeowners',
'too-big',
'labeller-recheck',
'bugfix',
'new-feature',
'new-feature-developer',
'breaking-change',
'developer-breaking-change',
'undocumented-api-change',
@@ -40,5 +43,17 @@ module.exports = {
// Keep matching the old esphome-docs name during the transition period
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
/esphome\/esphome-docs#\d+/
],
DEVELOPER_DOCS_PR_PATTERNS: [
/https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
/esphome\/developers\.esphome\.io#\d+/
],
// Files whose developer-facing changes are documented via Python docstrings
// only - developers.esphome.io has no reference page for them yet, so PRs
// touching nothing but these files (and tests/) skip needs-developer-docs.
DEV_DOCS_EXEMPT_FILES: [
'esphome/config_validation.py'
]
};
+60 -4
View File
@@ -1,4 +1,4 @@
const { DOCS_PR_PATTERNS } = require('./constants');
const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
const {
COMPONENT_REGEX,
detectComponents,
@@ -33,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
async function detectMergeBranch(context) {
async function detectMergeBranch(github, context) {
const labels = new Set();
const baseRef = context.payload.pull_request.base.ref;
@@ -42,7 +75,11 @@ async function detectMergeBranch(context) {
labels.add('merging-to-release');
} else if (baseRef === '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') {
// A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr');
}
@@ -245,6 +282,7 @@ async function detectPRTemplateCheckboxes(context) {
const checkboxPatterns = [
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
{ pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
@@ -355,12 +393,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
const labels = new Set();
// Check for missing tests
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
labels.add('needs-tests');
}
// Check for missing docs.
// `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
// `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
// deliberately excluded here: its docs live on developers.esphome.io and are
// checked separately below. `new-component` / `new-platform`
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
// i.e. the new component/platform is actually loadable from YAML.
const docsEligible =
@@ -376,6 +416,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
}
}
// Check for missing developer docs. `new-feature-developer` requires a
// developers.esphome.io PR link, unless every changed file outside tests/ is
// in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
if (allLabels.has('new-feature-developer')) {
const prBody = context.payload.pull_request.body || '';
const nonTestFiles = prFiles
.map(file => file.filename)
.filter(file => !file.startsWith('tests/'));
const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
if (!onlyExemptFiles && !hasDevDocsLink) {
labels.add('needs-developer-docs');
}
}
// Check for missing CODEOWNERS
if (allLabels.has('new-component')) {
const codeownersModified = prFiles.some(file =>
+2 -2
View File
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
// Early exit for release and beta branches only
if (baseRef === 'release' || baseRef === 'beta') {
const branchLabels = await detectMergeBranch(context);
const branchLabels = await detectMergeBranch(github, context);
const finalLabels = Array.from(branchLabels);
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
@@ -118,7 +118,7 @@ module.exports = async ({ github, context }) => {
deprecatedResult,
maintainerAccess
] = await Promise.all([
detectMergeBranch(context),
detectMergeBranch(github, context),
detectComponentPlatforms(changedFiles, apiData),
detectNewComponents(github, context, prFiles),
detectNewPlatforms(github, context, prFiles, apiData),
@@ -1,6 +1,14 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors');
const {
detectMergeBranch,
detectNewPlatforms,
detectNewComponents,
detectPRSize,
detectPRTemplateCheckboxes,
detectRequirements,
} = require('../detectors');
const { MANAGED_LABELS } = require('../constants');
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
// to check for CONFIG_SCHEMA in newly added files.
@@ -29,6 +37,107 @@ const API_DATA = {
const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
// ---------------------------------------------------------------------------
// detectMergeBranch
// ---------------------------------------------------------------------------
// Builds a fresh context for detectMergeBranch tests instead of mutating the
// shared CONTEXT fixture above (which other describe blocks rely on).
function makeMergeContext(baseRef, { stack } = {}) {
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
// ---------------------------------------------------------------------------
@@ -146,6 +255,125 @@ describe('detectNewComponents', () => {
});
});
// ---------------------------------------------------------------------------
// detectPRTemplateCheckboxes
// ---------------------------------------------------------------------------
const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
function makeBodyContext(body) {
return { payload: { pull_request: { body } } };
}
describe('detectPRTemplateCheckboxes', () => {
it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
assert.ok(labels.has('new-feature-developer'));
assert.ok(!labels.has('new-feature'));
});
it('unticked developer-facing feature checkbox adds no label', async () => {
const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
assert.ok(!labels.has('new-feature-developer'));
});
it('ticked new feature checkbox does not add new-feature-developer', async () => {
const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
assert.ok(labels.has('new-feature'));
assert.ok(!labels.has('new-feature-developer'));
});
});
// ---------------------------------------------------------------------------
// detectRequirements
// ---------------------------------------------------------------------------
describe('detectRequirements', () => {
// PR body without any docs-PR link.
const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
// File sets: a normal source change vs. one confined to the exempt core validators.
const SOURCE_FILES = [
{ filename: 'esphome/components/foo/foo.py' },
{ filename: 'tests/components/foo/common.yaml' },
];
const VALIDATOR_FILES = [
{ filename: 'esphome/config_validation.py' },
{ filename: 'tests/unit_tests/test_config_validation.py' },
];
it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-tests'));
assert.ok(!labels.has('needs-docs'));
});
it('new-feature-developer with has-tests does not add needs-tests', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
assert.ok(!labels.has('needs-tests'));
});
it('new-feature without a docs link still adds needs-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-docs'));
});
it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-developer-docs'));
});
it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
assert.ok(!labels.has('needs-developer-docs'));
});
it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
assert.ok(!labels.has('needs-developer-docs'));
});
it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-developer-docs'));
});
it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-docs'));
});
it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
assert.ok(!labels.has('needs-developer-docs'));
});
it('validator changes mixed with other source files are not exempt', async () => {
const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
assert.ok(labels.has('needs-developer-docs'));
});
});
// ---------------------------------------------------------------------------
// MANAGED_LABELS
// ---------------------------------------------------------------------------
describe('MANAGED_LABELS', () => {
it('includes new-feature-developer so the workflow syncs it', () => {
assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
});
it('includes needs-developer-docs so the workflow syncs it', () => {
assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
});
});
// ---------------------------------------------------------------------------
// detectPRSize
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate a token
id: generate-token
@@ -35,7 +35,7 @@ jobs:
# Scope the minted App token to the minimum needed by auto-label-pr/*.js.
permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
- name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+6 -3
View File
@@ -21,17 +21,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
# would only consume quota.
save-cache: "false"
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
+10 -8
View File
@@ -61,9 +61,9 @@ jobs:
tag: ${{ steps.tag.outputs.tag }}
push: ${{ steps.tag.outputs.push }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
@@ -71,11 +71,13 @@ jobs:
- name: Determine tag and whether to push
id: tag
env:
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
# Sanitize the branch name into a valid docker tag: replace invalid
# characters, ensure the first character is valid (tags must start
# with [A-Za-z0-9_]), and cap the length at 128 characters.
branch="${{ github.head_ref || github.ref_name }}"
branch="$HEAD_REF"
tag="${branch//[^a-zA-Z0-9_.-]/-}"
case "$tag" in
[a-zA-Z0-9_]*) ;;
@@ -96,7 +98,7 @@ jobs:
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -145,16 +147,16 @@ jobs:
- "ha-addon"
- "docker"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to the GitHub container registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -202,7 +204,7 @@ jobs:
- nrf52
- host
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Run tests
working-directory: .github/scripts/auto-label-pr
@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo
+403 -249
View File
@@ -28,13 +28,13 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate cache-key
id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
@@ -49,9 +49,12 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@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.
@@ -65,196 +68,6 @@ jobs:
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: 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-rp2040-boards.py --check
script/ci_check_duplicate_test_ids.py
import-time:
name: Check import esphome.__main__ time
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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:
name: Determine which jobs to run
runs-on: ubuntu-24.04
@@ -285,7 +98,7 @@ jobs:
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -344,6 +157,204 @@ jobs:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
ci-custom:
name: Run script/ci-custom
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
- name: Run script/ci-custom
run: |
. venv/bin/activate
script/ci-custom.py
script/build_codeowners.py --check
script/build_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()
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
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 }}
# Inlined from esphome/pre-commit-action with a restore-only cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env:
SKIP: pylint,ci-custom
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
pre-commit-seed-cache:
name: Seed pre-commit cache
runs-on: ubuntu-latest
needs:
- common
# Saves a dev-scoped pre-commit cache that pull request runs can
# restore, since pre-commit.ci lite itself never runs on dev pushes.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pytest:
name: Run pytest
strategy:
fail-fast: false
matrix:
python-version:
- "3.12"
- "3.13"
- "3.14"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
exclude:
# Minimize CI resource usage
# by only running the Python version
# version used for docker images on Windows and macOS
- python-version: "3.13"
os: windows-latest
- python-version: "3.13"
os: macOS-latest
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ matrix.python-version }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run pytest
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
codecov-empty-upload:
name: Report no coverage to Codecov
runs-on: ubuntu-24.04
needs:
- determine-jobs
# ``pytest`` is the only job that uploads coverage, and it is skipped when
# every changed file is CI-irrelevant (see ``should_run_core_ci`` in
# ``script/determine-jobs.py``). With no upload Codecov never reports a
# result, so the required ``codecov/patch`` status stays pending forever and
# the pull request can never be merged. Tell Codecov up front that this
# commit has nothing to cover so it publishes a passing status instead.
#
# ``force`` skips Codecov's own check that every changed file is ignorable;
# ``determine-jobs`` has already decided none of these files can affect
# coverage, and Codecov would otherwise fail the status for paths it does
# not recognise as non-testable (``docker/**``, ``.yamllint``).
if: needs.determine-jobs.outputs.core-ci == 'false'
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Report empty upload to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
run_command: empty-upload
force: true
fail_ci_if_error: true
integration-tests:
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
@@ -357,10 +368,28 @@ jobs:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install ccache
# Speeds up the host compiles: tests in a bucket compile overlapping
# component sets, so later tests reuse earlier tests' objects.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ccache
- name: Restore ccache (restore-only)
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py). The
# bucket-name prefix prefers a same-bucket seed; the bare prefix falls
# back to any seed when the bucket layout differs from dev.
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
restore-keys: |
integration-ccache-${{ matrix.bucket.name }}-
integration-ccache-
- name: Set up Python 3.13
id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Restore Python virtual environment
@@ -372,9 +401,12 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
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.
@@ -399,33 +431,46 @@ jobs:
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
- name: Print ccache statistics
# esphome stores the PlatformIO ccache under the machine-global cache
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
- name: Save ccache
# Pull request saves land in per-PR scopes nothing else can reuse;
# dev pushes seed the shared copy instead.
if: github.event_name != 'pull_request'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/esphome/platformio-ccache
key: integration-ccache-${{ matrix.bucket.name }}-${{ github.sha }}
cpp-unit-tests:
name: Run C++ unit tests
import-time:
name: Check import esphome.__main__ time
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 != '[]')
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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
- name: Check import time against budget and write waterfall HAR
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
script/check_import_time.py --check --har importtime.har
- name: Upload waterfall HAR
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: import-time-waterfall
path: importtime.har
if-no-files-found: ignore
retention-days: 14
benchmarks:
name: Run CodSpeed benchmarks
@@ -438,7 +483,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -456,7 +501,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1
with:
run: |
. venv/bin/activate
@@ -464,6 +509,33 @@ jobs:
pytest tests/benchmarks/python/ --codspeed --no-cov
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:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
@@ -475,6 +547,8 @@ jobs:
GH_TOKEN: ${{ github.token }}
# esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache.
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
# nrf52-tidy installs sdk-nrf natively; pin it to a cacheable path.
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: 2
@@ -491,12 +565,21 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for ZEPHYR
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
pio_cache_key: tidy-zephyr
cache_sdk_nrf: true
ignore_errors: false
- id: clang-tidy
name: Run script/clang-tidy for RP2
options: --environment rp2-tidy --grep USE_RP2
pio_cache_key: tidyrp2
- id: clang-tidy
name: Run script/clang-tidy for LibreTiny
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
pio_cache_key: tidylibretiny
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -527,6 +610,10 @@ jobs:
with:
framework: arduino
- name: Cache nRF Connect SDK install
if: matrix.cache_sdk_nrf
uses: ./.github/actions/cache-sdk-nrf
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
@@ -552,10 +639,21 @@ jobs:
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
changed=""
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
changed="--changed"
fi
if [ -n "${{ matrix.environments }}" ]; then
rc=0
for env in ${{ matrix.environments }}; do
echo "::group::clang-tidy $env"
script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1
echo "::endgroup::"
done
exit $rc
else
script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
@@ -579,7 +677,7 @@ jobs:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -659,7 +757,7 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -729,7 +827,8 @@ jobs:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 S3
options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
@@ -739,11 +838,11 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
# yamllint disable-line rule:line-length
options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -805,6 +904,9 @@ jobs:
# esp32 component builds use the native ESP-IDF toolchain (default), so
# share the tidy jobs' install location -- the restore below lands here.
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
# nrf52 component builds install sdk-nrf natively; pin it to the shared
# cacheable path so the restore below lands where the build looks.
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
@@ -819,14 +921,15 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -840,6 +943,14 @@ jobs:
uses: ./.github/actions/cache-esp-idf
with:
restore-only: true
- name: Cache nRF Connect SDK install (restore-only)
# Only batches whose test platforms include nrf52 need the native
# sdk-nrf install; never save -- just reuse the shared install the
# dev nrf52 tidy job cached when present.
if: matrix.batch.needs_nrf
uses: ./.github/actions/cache-sdk-nrf
with:
restore-only: true
- name: Validate and compile components with intelligent grouping
run: |
. venv/bin/activate
@@ -963,7 +1074,7 @@ jobs:
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -989,26 +1100,62 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
- name: Check out esphome (this PR)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
env:
SKIP: pylint,ci-custom
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Set up uv
# Mirrors the install shape device-builder's own CI uses
# (esphome/device-builder#192): uv replaces pip for the
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@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
memory-impact-target-branch:
name: Build target branch for memory impact
@@ -1024,7 +1171,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps:
- name: Check out target branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.base_ref }}
@@ -1206,7 +1353,7 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1275,7 +1422,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1309,21 +1456,28 @@ jobs:
ci-status:
name: CI Status
runs-on: ubuntu-24.04
# Listed in the same order the jobs are defined above. Two jobs are
# deliberately left out: "benchmarks" reports through CodSpeed rather than
# this check, and "pre-commit-seed-cache" only populates a cache on pushes
# to dev.
needs:
- common
- determine-jobs
- ci-custom
- pylint
- pre-commit-ci-lite
- pytest
- codecov-empty-upload
- integration-tests
- import-time
- cpp-unit-tests
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- clang-tidy-esp32-variants
- determine-jobs
- device-builder
- test-build-components-split
- test-esp32-platformio
- pre-commit-ci-lite
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
- memory-impact-comment
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
+3 -3
View File
@@ -52,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+11 -11
View File
@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get tag
id: tag
# yamllint disable rule:line-length
@@ -60,9 +60,9 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.x"
- name: Build
@@ -70,7 +70,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -92,9 +92,9 @@ jobs:
os: "ubuntu-24.04-arm"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
@@ -102,12 +102,12 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to docker hub
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -168,7 +168,7 @@ jobs:
- ghcr
- dockerhub
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -182,13 +182,13 @@ jobs:
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+35 -50
View File
@@ -6,61 +6,46 @@ on:
- cron: "30 0 * * *"
workflow_dispatch:
permissions:
issues: write # actions/stale labels, comments on, and closes stale issues
pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
concurrency:
group: lock
# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
# needs no permissions at all.
permissions: {}
jobs:
stale:
if: github.repository_owner == 'esphome'
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
operations-per-run: 400
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@203cea60ebfd18e2b966e57750750e0417a9feec # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
# Live only on dev: a workflow_dispatch from any other branch is a dry run
dry-run: ${{ github.ref != 'refs/heads/dev' }}
days-before-stale: 90
days-before-close: 7
stale-label: stale
exempt-label: not-stale
ignored-users: esphbot,codecov-commenter
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
# The 90 day stale policy for PRs
# - PRs
# - No PRs marked as "not-stale"
# - No Issues (see below)
days-before-pr-stale: 90
days-before-pr-close: 7
stale-pr-label: "stale"
exempt-pr-labels: "not-stale"
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Thank you for your contribution!
Please make sure to update to the latest ESPHome version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
# The 90 day stale policy for Issues
# - Issues
# - No Issues marked as "not-stale"
# - No PRs (see above)
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
exempt-issue-labels: "not-stale"
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
high number of incoming GitHub notifications, we have to clean some
of the old issues, as many of them have already been resolved with
the latest updates.
Please make sure to update to the latest ESPHome version and
check if that solves the issue. Let us know if that works for you by
adding a comment 👍
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
This issue has now been marked as stale and will be closed if no
further activity occurs. Thank you for your contributions.
+2 -2
View File
@@ -5,7 +5,7 @@ on:
types: [opened, reopened, labeled, unlabeled, synchronize]
permissions:
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
@@ -20,7 +20,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
+6 -6
View File
@@ -28,16 +28,16 @@ jobs:
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Checkout Home Assistant
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: home-assistant/core
path: lib/home-assistant
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``pre-commit`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
@@ -96,8 +96,8 @@ jobs:
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>
author: esphomebot <esphome@openhomefoundation.org>
committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
branch: sync/device-classes
delete-branch: true
title: "Synchronise Device Classes from Home Assistant"
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.15
rev: v0.16.0
hooks:
# Run the linter.
- id: ruff
+65 -23
View File
@@ -191,11 +191,14 @@ This document provides essential context for AI models interacting with this pro
my_component_ns = cg.esphome_ns.namespace("my_component")
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Required(CONF_KEY): cv.string,
cv.Optional(CONF_PARAM, default=42): cv.int_,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -229,7 +232,12 @@ This document provides essential context for AI models interacting with this pro
- **Sensor:**
```python
from esphome.components import sensor
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
cv.polling_component_schema("60s")
)
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
@@ -238,7 +246,10 @@ This document provides essential context for AI models interacting with this pro
- **Binary Sensor:**
```python
from esphome.components import binary_sensor
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
```
@@ -246,7 +257,10 @@ This document provides essential context for AI models interacting with this pro
- **Switch:**
```python
from esphome.components import switch
CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
CONFIG_SCHEMA = switch.switch_schema().extend({...})
async def to_code(config):
var = await switch.new_switch(config)
```
@@ -263,10 +277,13 @@ This document provides essential context for AI models interacting with this pro
```python
from esphome import automation
CONFIG_SCHEMA = cv.Schema({
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}).extend(cv.COMPONENT_SCHEMA)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(MyComponent),
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -316,11 +333,14 @@ This document provides essential context for AI models interacting with this pro
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
CONFIG_SCHEMA = cv.Schema({
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
})
CONFIG_SCHEMA = cv.Schema(
{
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
),
}
)
async def to_code(config):
for conf in config.get(CONF_ON_TURN_ON, []):
@@ -368,7 +388,10 @@ This document provides essential context for AI models interacting with this pro
```
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
* **Configuration Validation:**
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
@@ -381,6 +404,7 @@ This document provides essential context for AI models interacting with this pro
.extend(i2c.i2c_device_schema(0x48))
.extend(spi.spi_device_schema(cs_pin_required=True))
```
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
## 5. Key Files & Entrypoints
@@ -427,13 +451,14 @@ This document provides essential context for AI models interacting with this pro
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
```yaml
# test.esp32-idf.yaml — use packages for buses
# test.esp32-idf.yaml — everything included via named packages
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
my_component: !include common.yaml
```
```yaml
# common.yaml — component config only, NO bus definitions
@@ -470,7 +495,7 @@ This document provides essential context for AI models interacting with this pro
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.
5. **Commit:** Commit your changes. There is no strict format for commit messages.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
* **Documentation Contributions:**
* Documentation is hosted in the separate `esphome/esphome.io` repository.
@@ -616,6 +641,7 @@ This document provides essential context for AI models interacting with this pro
_component_state = []
_use_feature = None
def enable_feature():
global _use_feature
_use_feature = True
@@ -635,20 +661,24 @@ This document provides essential context for AI models interacting with this pro
DOMAIN = "my_component"
@dataclass
class MyComponentData:
feature_enabled: bool = False
item_count: int = 0
items: list[str] = field(default_factory=list)
def _get_data() -> MyComponentData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = MyComponentData()
return CORE.data[DOMAIN]
def request_feature() -> None:
_get_data().feature_enabled = True
def add_item(item: str) -> None:
_get_data().items.append(item)
```
@@ -703,10 +733,22 @@ This document provides essential context for AI models interacting with this pro
```
* **Deprecation Pattern (Python):**
For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
```python
CONFIG_SCHEMA = cv.All(
cv.rename_key(
CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
),
cv.Schema({ ... }),
)
```
For other deprecations, warn manually during validation:
```python
# Remove before 2026.6.0
if CONF_OLD_KEY in config:
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
_LOGGER.warning(
f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
)
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
```
## 9. English Language
+10 -1
View File
@@ -69,11 +69,14 @@ esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
esphome/components/bk72xx/* @kuba2k2
esphome/components/bk72xx_ble/* @Bl00d-B0b
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis
@@ -122,6 +125,7 @@ esphome/components/cover/* @esphome/core
esphome/components/cs5460a/* @balrog-kun
esphome/components/cse7761/* @berfenger
esphome/components/cst226/* @clydebarrow
esphome/components/cst328/* @latonita
esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz
@@ -144,6 +148,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
@@ -186,6 +191,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/fs3000/* @kahrendt
@@ -207,6 +213,7 @@ esphome/components/gree/switch/* @nagyrobi
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
esphome/components/grove_tb6612fng/* @max246
esphome/components/growatt_solar/* @leeuwte
esphome/components/gsl3670/* @clydebarrow
esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn
@@ -288,6 +295,7 @@ esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
@@ -403,6 +411,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz
@@ -425,7 +434,7 @@ esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz
esphome/components/ring_buffer/* @kahrendt
esphome/components/router/speaker/* @kahrendt
esphome/components/rp2040/* @jesserockz
esphome/components/rp2/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.7.0-dev
PROJECT_NUMBER = 2026.8.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1
View File
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
recursive-include esphome requirements.txt
+46
View File
@@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
below their documented guarantees.
## The web server is an open HTTP API by design
The `web_server` component exposes a plain HTTP interface for viewing and
controlling entities, and, when the `web_server` OTA platform is enabled, for
uploading firmware at `/update`. Its only access controls are the optional
`web_server` `auth:` credentials and the network the device sits on.
When `auth:` is not configured, every endpoint is reachable by any client that
can reach the device. This is intentional; enabling `web_server` without `auth:`
is choosing an open control surface, in the same way that running native OTA
without a password leaves OTA open. The API is documented and is meant to be
called by other devices, scripts, and pages.
As defense-in-depth, the web server checks the `Origin` header on browser requests
to its entity control and state endpoints: a request whose `Origin` does not match
the address the device is served on is rejected, and the `allowed_origins` option
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
the operator visits drives the device through their browser. It is **not** an
authentication boundary: it only constrains browsers. Any client that omits the
`Origin` header — `curl`, scripts, or other non-browser callers on the same
network — reaches every endpoint exactly as before. The check also does not cover
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
validation. The following are therefore **not** vulnerabilities in this repository:
- Requests without an `Origin` header (for example `curl`) reaching the control
endpoints, whether or not `web_server` `auth:` is set.
- Requests from an origin the operator added to `allowed_origins`.
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
covered by the `Origin` check; this is the same exposure as running OTA without a
password.
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
a native OTA password), and keeping devices on a trusted, segmented network. See
the security best practices guide linked above.
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
and any memory-safety or protocol bug in the server reachable without credentials.
This section documents the current design and scope; it is not a judgment that the
design is optimal or that it will not change.
## Explicitly out of scope
- Local attackers who already have shell access on the host that runs `esphome`.
@@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
- Attacks that require an already-authenticated device peer (someone who already
holds the API key / OTA / web credentials).
- Access to the device web server or its web OTA endpoint by non-browser clients
(those that send no `Origin` header). The web server is an open HTTP API by
design (see above); browser cross-origin requests are blocked by default, but the
real controls are `web_server` `auth:` and network isolation.
- Anything in the dashboard / device-builder — report that in its own repository
(linked at the top).
- Deployments where the operator removed protections or exposed credentials. See
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.0
RUN \
platformio settings set enable_telemetry No \
+34 -12
View File
@@ -16,14 +16,10 @@ import sys
import time
from typing import Protocol
import argcomplete
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one.
from esphome import const
import esphome.codegen as cg
from esphome.config import iter_component_configs, read_config, strip_default_ids
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
@@ -355,7 +351,7 @@ def choose_upload_log_host(
bootsel_permission_error = False
if (
purpose == Purpose.UPLOADING
and CORE.is_rp2040
and CORE.is_rp2
and (picotool := _find_picotool()) is not None
):
bootsel = detect_rp2040_bootsel(picotool)
@@ -402,7 +398,7 @@ def choose_upload_log_host(
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if (
purpose == Purpose.UPLOADING
and CORE.is_rp2040
and CORE.is_rp2
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
):
if bootsel_permission_error:
@@ -704,6 +700,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
def _wrap_to_code(name, comp, yaml_util):
import esphome.codegen as cg
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
@@ -739,6 +737,7 @@ def write_cpp(config: ConfigType) -> int:
def generate_cpp_contents(config: ConfigType) -> None:
from esphome import yaml_util
from esphome.config import iter_component_configs
_LOGGER.info("Generating C++ source...")
@@ -776,6 +775,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
check_placeholder_credentials(config)
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
# working on machines that cannot run the toolchain.
if CORE.is_esp8266:
from esphome.components.esp8266 import check_rosetta
check_rosetta()
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -985,7 +991,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int:
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# the upload target, but 'nobuild' skips the build phase that creates it.
# Create it here so the upload doesn't fail.
if CORE.is_rp2040:
if CORE.is_rp2:
idedata = toolchain.get_idedata(config)
build_dir = Path(idedata.firmware_elf_path).parent
firmware_bin = build_dir / "firmware.bin"
@@ -1173,7 +1179,7 @@ def upload_program(
if CORE.is_esp32 or CORE.is_esp8266:
file = getattr(args, "file", None)
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
elif CORE.is_rp2040 or CORE.is_libretiny:
elif CORE.is_rp2 or CORE.is_libretiny:
exit_code = upload_using_platformio(config, host)
# else: Unknown target platform, exit_code remains 1
@@ -1457,6 +1463,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
from esphome.config import strip_default_ids
if getattr(args, "no_defaults", False):
user_config = getattr(config, "user_config", None)
@@ -1510,10 +1517,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
m = _LEGACY_REDACTION_RE.search(line)
if m is None:
continue
key = m.group("key")
if not in_substitutions:
unmarked.add(m.group("key"))
# Public keys (e.g. wireguard's peer_public_key) are not secret;
# redacting them and telling maintainers to mark them cv.sensitive
# would be wrong on both counts. Substitution keys are user-named
# with no schema behind them, so anything secret-shaped there
# (public or not) stays conservatively redacted.
if "public" in key.split("_"):
continue
unmarked.add(key)
lines[i] = (
f"{line[: m.start()]}{m.group('key')}: "
f"{line[: m.start()]}{key}: "
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
)
output = "\n".join(lines)
@@ -1647,7 +1662,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# After BOOTSEL upload, wait for a new serial port to appear
# so it shows up in the log chooser
if successful_device is None and CORE.is_rp2040:
if successful_device is None and CORE.is_rp2:
_wait_for_serial_port(known_ports=pre_upload_ports)
# If exactly one new serial port appeared, use it directly
serial_ports = get_serial_ports()
@@ -2483,7 +2498,12 @@ def parse_args(argv):
# a deprecation warning).
arguments = argv[1:]
argcomplete.autocomplete(parser)
# argcomplete only does anything when the shell-completion machinery
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
if "_ARGCOMPLETE" in os.environ:
import argcomplete
argcomplete.autocomplete(parser)
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
args, unknown_args = parser.parse_known_args(arguments)
@@ -2582,6 +2602,8 @@ def run_esphome(argv):
)
if config is None:
from esphome.config import read_config
config = read_config(
command_line_substitutions,
skip_external_update=skip_external,
+8 -27
View File
@@ -20,6 +20,7 @@ from . import (
RAM_SECTIONS,
MemoryAnalyzer,
)
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
@@ -759,45 +760,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
# Find firmware.elf
elf_file = None
for elf_candidate in [
build_path / "firmware.elf",
build_path / ".pioenvs" / build_path.name / "firmware.elf",
]:
if elf_candidate.exists():
elf_file = str(elf_candidate)
break
if not elf_file:
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
elf_path = find_elf_path(build_path)
if not elf_path:
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
# Find idedata.json - check current directory first, then home
device_name = build_path.name
idedata_candidates = [
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
]
elf_file = str(elf_path)
idedata = None
for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
if idedata_path := find_idedata_path(build_path):
try:
with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
print(
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr,
)
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
+34 -10
View File
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
@@ -65,6 +65,7 @@ class RamSymbol:
size: int
section: str
demangled: str = "" # Demangled name, set after batch demangling
aliases: list[str] = field(default_factory=list) # Other names at same address
class RamStringsAnalyzer:
@@ -235,6 +236,11 @@ class RamStringsAnalyzer:
except (subprocess.CalledProcessError, FileNotFoundError):
return
# Track symbols by address so aliases (multiple names for the same
# object, e.g. the newlib __lock___* mutexes that all alias one
# StaticSemaphore_t) are reported once instead of once per name.
symbols_by_addr: dict[int, RamSymbol] = {}
for line in output.split("\n"):
parts = line.split()
if len(parts) < 4:
@@ -253,6 +259,18 @@ class RamStringsAnalyzer:
if sym_type not in DATA_SYMBOL_TYPES:
continue
if (existing := symbols_by_addr.get(addr)) is not None:
# Prefer a global (uppercase type) name as the primary so
# nm output order can't hide it behind a local alias.
if sym_type.isupper() and existing.sym_type.islower():
existing.aliases.append(existing.name)
existing.name = name
existing.sym_type = sym_type
else:
existing.aliases.append(name)
existing.size = max(existing.size, size)
continue
# Check if symbol is in a RAM section
for section_name in self.ram_sections:
if section_name not in self.sections:
@@ -260,15 +278,15 @@ class RamStringsAnalyzer:
section = self.sections[section_name]
if section.address <= addr < section.address + section.size:
self.ram_symbols.append(
RamSymbol(
name=name,
sym_type=sym_type,
address=addr,
size=size,
section=section_name,
)
symbol = RamSymbol(
name=name,
sym_type=sym_type,
address=addr,
size=size,
section=section_name,
)
symbols_by_addr[addr] = symbol
self.ram_symbols.append(symbol)
break
def _demangle_symbols(self) -> None:
@@ -436,7 +454,13 @@ class RamStringsAnalyzer:
for symbol in largest_symbols:
# Use demangled name if available, otherwise raw name
display_name = symbol.demangled or symbol.name
name_display = display_name[:49] if len(display_name) > 49 else display_name
# Truncate the name, not the alias note, so merged aliases stay
# visible even for long demangled C++ names.
alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
max_name_len = 49 - len(alias_note)
if len(display_name) > max_name_len:
display_name = display_name[:max_name_len]
name_display = display_name + alias_note
lines.append(
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
)
+72
View File
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
]
def find_elf_path(build_path: Path) -> Path | None:
"""Locate the firmware ELF inside an ESPHome build directory.
The layout depends on the toolchain that produced the build, so try each
known one in turn.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the ELF file, or None if no known layout matches
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
build_path / "firmware.elf",
build_path / ".pioenvs" / name / "firmware.elf",
# LibreTiny uses raw_firmware.elf
build_path / "raw_firmware.elf",
build_path / ".pioenvs" / name / "raw_firmware.elf",
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
):
if candidate.is_file():
return candidate
return None
def idedata_candidates(build_path: Path) -> list[Path]:
"""Return the idedata locations searched for a build directory, in order.
Exposed so a caller reporting "not found" can name the paths it tried
without keeping its own copy of the list.
Args:
build_path: Path to an ESPHome build directory
Returns:
The candidate idedata JSON paths, most specific first
"""
name = build_path.name
return [
# In .pioenvs for test builds
build_path / ".pioenvs" / name / "idedata.json",
# Both toolchains cache it in the data dir, which holds this build dir:
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
build_path.parent.parent / "idedata" / f"{name}.json",
# Regular builds, invoked from the config dir or from anywhere
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
Path.home() / ".esphome" / "idedata" / f"{name}.json",
]
def find_idedata_path(build_path: Path) -> Path | None:
"""Locate the idedata JSON belonging to an ESPHome build directory.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the idedata JSON, or None if it was not found
"""
for candidate in idedata_candidates(build_path):
if candidate.is_file():
return candidate
return None
def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories.
+27 -1
View File
@@ -6,7 +6,11 @@ from pathlib import Path
from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags
from esphome.framework_helpers import (
get_project_compile_flags,
get_project_cxx_compile_flags,
get_project_link_flags,
)
from esphome.helpers import mkdir_p, write_file_if_changed
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
@@ -91,6 +95,14 @@ def get_project_cmakelists(minimal: bool = False) -> str:
for flag in project_compile_opts
)
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
# -Wno-volatile is passed on a C compile.
cxx_compile_options = "\n".join(
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
for flag in get_project_cxx_compile_flags()
)
cpp_standard_options = (
CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
if CORE.cpp_standard
@@ -155,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{cpp_standard_options}
{cxx_compile_options}
{extra_compile_options}
{managed_components_property}
@@ -196,15 +210,27 @@ def get_component_cmakelists() -> str:
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
+2 -3
View File
@@ -108,7 +108,6 @@ Import("env")
def write_cxx_flags_script() -> None:
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
contents = CXX_FLAGS_FILE_CONTENTS
if not CORE.is_host:
contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
contents += "\n"
for flag in sorted(CORE.cxx_build_flags):
contents += f'env.Append(CXXFLAGS=["{flag}"])\n'
write_file_if_changed(path, contents)
+135 -3
View File
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
import io
import json
import logging
from pathlib import Path
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
import re
import shutil
import tarfile
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bundle"
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
@@ -49,6 +51,7 @@ class ManifestKey(StrEnum):
MANIFEST_VERSION = "manifest_version"
ESPHOME_VERSION = "esphome_version"
CONFIG_FILENAME = "config_filename"
CONFIG_DIR = "config_dir"
FILES = "files"
HAS_SECRETS = "has_secrets"
@@ -120,6 +123,126 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
@dataclass
class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list)
# Original config dir parsed from an extracted bundle's manifest.json,
# kept in the path flavor of the machine the bundle was created on.
# The checked flag makes the manifest lookup happen at most once per run;
# CORE.data is cleared between runs.
original_config_dir: PurePath | None = None
original_config_dir_checked: bool = False
def _get_data() -> BundleData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = BundleData()
return CORE.data[DOMAIN]
def add_bundle_file(path: Path) -> None:
"""Register a file that a bundle must include.
Bundle discovery walks the validated config, so it only finds files the config
names. Components call this during validation for files it cannot see, such as a
file that is referenced from inside another file.
A relative path is taken as relative to the config directory. Files outside the
config directory are skipped when the bundle is built.
"""
_get_data().extra_files.append(CORE.relative_config_path(path))
# Windows paths start with a drive letter or contain backslashes; POSIX
# paths do neither in practice, so this is how the flavor of a recorded
# path string is recognized on any host.
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
def _path_flavor(value: str) -> type[PurePath]:
"""Pick the pure path class matching the flavor ``value`` was written in."""
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
return PureWindowsPath
return PurePosixPath
def _load_original_config_dir() -> PurePath | None:
"""Read the original config dir from an extracted bundle's manifest.
Returns None when the current config dir is not an extracted bundle or
the manifest does not record the original config dir.
"""
manifest_path = CORE.config_dir / MANIFEST_FILENAME
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError:
# The common case: this config dir is not an extracted bundle.
return None
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
# A manifest.json is present but unreadable or malformed. Say so
# instead of letting it look identical to "not a bundle".
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
return None
if not isinstance(manifest, dict):
return None
# A manifest.json in the config dir does not have to be ours. Only trust
# one that looks like a bundle manifest for exactly this config file.
version = manifest.get(ManifestKey.MANIFEST_VERSION)
if not isinstance(version, int) or version < 1:
return None
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
return None
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
if not isinstance(config_dir, str) or not config_dir:
return None
return _path_flavor(config_dir)(config_dir)
def remap_bundle_path(value: str) -> Path | None:
"""Remap an absolute path from the machine a bundle was created on.
A bundled config may reference files by absolute path. The referenced
files ship inside the bundle at their config-relative locations, but the
YAML text is copied verbatim, so after extraction on another machine the
absolute reference points at a path that only existed on the creating
machine. The bundle manifest records that machine's config dir; when
``value`` names a path that lived under it, return the corresponding
file next to the extracted config.
``value`` is the raw path string from the config. It is parsed with the
original machine's path flavor, so a bundle created on Windows remaps on
a POSIX build server and vice versa.
Returns None when not compiling an extracted bundle, when ``value`` was
not under the original config dir, or when the bundle does not contain
the file.
"""
data = _get_data()
if not data.original_config_dir_checked:
data.original_config_dir_checked = True
data.original_config_dir = _load_original_config_dir()
original_dir = data.original_config_dir
if original_dir is None:
return None
path = type(original_dir)(value)
if not path.is_absolute():
return None
try:
rel = path.relative_to(original_dir)
except ValueError:
return None
# relative_to is lexical, so ".." segments survive it. Refuse them: the
# remapped file must land strictly inside the extracted config tree.
if ".." in rel.parts:
return None
remapped = CORE.relative_config_path(Path(*rel.parts))
if not remapped.exists():
return None
return remapped
@dataclass
class BundleFile:
"""A file to include in the bundle."""
@@ -146,6 +269,7 @@ class BundleManifest:
config_filename: str
files: list[str]
has_secrets: bool
config_dir: str | None = None
class ConfigBundleCreator:
@@ -286,13 +410,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
are handled explicitly.
are handled explicitly. Files the config does not name at all are
registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
# Files registered by components during validation
for extra_file in _get_data().extra_files:
self._add_file(extra_file)
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
@@ -405,6 +534,7 @@ class ConfigBundleCreator:
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.ESPHOME_VERSION: const.__version__,
ManifestKey.CONFIG_FILENAME: self._config_path.name,
ManifestKey.CONFIG_DIR: str(self._config_dir),
ManifestKey.FILES: [f.path for f in files],
ManifestKey.HAS_SECRETS: has_secrets,
}
@@ -489,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
except tarfile.TarError as err:
raise EsphomeError(f"Failed to read bundle: {err}") from err
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
return BundleManifest(
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
files=manifest.get(ManifestKey.FILES, []),
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
config_dir=config_dir if isinstance(config_dir, str) else None,
)
+2
View File
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
add,
add_build_flag,
add_build_unflag,
add_cxx_build_flag,
add_define,
add_global,
add_library,
@@ -52,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401
past_safe_mode,
register_component,
register_parented,
set_setup_priority,
)
from esphome.cpp_types import ( # noqa: F401
NAN,
+6
View File
@@ -0,0 +1,6 @@
# Importing `esphome.loader` here installs the component-alias
# ``sys.meta_path`` finder before any submodule lookup runs. Without this,
# `from esphome.components import <legacy_alias>` from a fresh interpreter
# can race the finder install and raise ImportError, since the legacy
# alias dir no longer exists on disk.
from esphome import loader as _loader # noqa: F401
+4 -4
View File
@@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
def validate_adc_pin(value):
if str(value).upper() == "VCC":
if CORE.is_rp2040:
if CORE.is_rp2:
return pins.internal_gpio_input_pin_schema(29)
return cv.only_on([PLATFORM_ESP8266])("VCC")
if str(value).upper() == "TEMPERATURE":
return cv.only_on_rp2040("TEMPERATURE")
return cv.only_on_rp2("TEMPERATURE")
if CORE.is_esp32:
conf = pins.internal_gpio_input_pin_schema(value)
@@ -261,11 +261,11 @@ def validate_adc_pin(value):
raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC")
return conf
if CORE.is_rp2040:
if CORE.is_rp2:
conf = pins.internal_gpio_input_pin_schema(value)
number = conf[CONF_NUMBER]
if number not in (26, 27, 28, 29):
raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC")
raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC")
return conf
if CORE.is_libretiny:
+4 -4
View File
@@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
void set_autorange(bool autorange) { this->autorange_ = autorange; }
#endif // USE_ESP32
#ifdef USE_RP2040
#ifdef USE_RP2
void set_is_temperature() { this->is_temperature_ = true; }
#endif // USE_RP2040
#endif // USE_RP2
protected:
uint8_t sample_count_{1};
@@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
static adc_oneshot_unit_handle_t shared_adc_handles[2];
#endif // USE_ESP32
#ifdef USE_RP2040
#ifdef USE_RP2
bool is_temperature_{false};
#endif // USE_RP2040
#endif // USE_RP2
#ifdef USE_ZEPHYR
const struct adc_dt_spec *channel_ = nullptr;
@@ -1,4 +1,4 @@
#ifdef USE_RP2040
#ifdef USE_RP2
#include "adc_sensor.h"
#include "esphome/core/log.h"
@@ -17,7 +17,7 @@
namespace esphome::adc {
static const char *const TAG = "adc.rp2040";
static const char *const TAG = "adc.rp2";
void ADCSensor::setup() {
static bool initialized = false;
@@ -102,4 +102,4 @@ float ADCSensor::sample() {
} // namespace esphome::adc
#endif // USE_RP2040
#endif // USE_RP2
+1 -1
View File
@@ -211,7 +211,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF,
},
"adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO},
"adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO},
"adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
"adc_sensor_libretiny.cpp": {
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.RTL87XX_ARDUINO,
+20 -98
View File
@@ -1,114 +1,36 @@
import logging
# ---------------------------------------------------------------------------
# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
# 2027.1.0.
#
# Animations are now a platform of the `image:` component (`platform:
# animation`); the real schema, actions and codegen live in `image.py`. This
# module only keeps the deprecated top-level `animation:` key working during the
# deprecation window: it reuses that schema/codegen and adds a one-shot
# deprecation warning (with a pasteable migrated `image:` block) at validation
# time. Deleting this file drops the top-level form entirely.
# ---------------------------------------------------------------------------
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
import esphome.components.image as espImage
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
_LOGGER = logging.getLogger(__name__)
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
AUTO_LOAD = ["image"]
AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
MULTI_CONF = True
MULTI_CONF_NO_DEFAULT = True
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
DOMAIN = "animation"
animation_ns = cg.esphome_ns.namespace("animation")
LEGACY_REMOVAL_VERSION = "2027.1.0"
Animation_ = animation_ns.class_("Animation", espImage.Image_)
# Actions
NextFrameAction = animation_ns.class_(
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
)
PrevFrameAction = animation_ns.class_(
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
)
SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
_capture_legacy_entry, _warn_legacy_animation = (
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
)
CONFIG_SCHEMA = cv.All(
espImage.IMAGE_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(Animation_),
cv.Optional(CONF_LOOP): cv.All(
{
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
cv.Optional(CONF_END_FRAME): cv.positive_int,
cv.Optional(CONF_REPEAT): cv.positive_int,
}
),
},
),
espImage.validate_settings,
)
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
SET_FRAME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(Animation_),
cv.Required(CONF_FRAME): cv.uint16_t,
}
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (frame := config.get(CONF_FRAME)) is not None:
template_ = await cg.templatable(frame, args, cg.uint16)
cg.add(var.set_frame(template_))
return var
async def to_code(config):
(
prog_arr,
width,
height,
image_type,
trans_value,
frame_count,
) = await espImage.write_image(config, all_frames=True)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
frame_count,
image_type,
trans_value,
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
to_code = setup_animation
+115
View File
@@ -0,0 +1,115 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
from esphome.types import ConfigType
CODEOWNERS = ["@syndlex"]
AUTO_LOAD = ["file"]
DEPENDENCIES = ["display"]
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
animation_ns = cg.esphome_ns.namespace("animation")
Animation_ = animation_ns.class_("Animation", Image_)
# Actions
NextFrameAction = animation_ns.class_(
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
)
PrevFrameAction = animation_ns.class_(
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
)
SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
)
ANIMATION_SCHEMA = image_schema(Animation_).extend(
{
cv.Optional(CONF_LOOP): cv.All(
{
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
cv.Optional(CONF_END_FRAME): cv.positive_int,
cv.Optional(CONF_REPEAT): cv.positive_int,
}
),
},
)
# Shared schema used by both the (deprecated) top-level `animation:` key and the
# `image:` `platform: animation` entry.
ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
SET_FRAME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(Animation_),
cv.Required(CONF_FRAME): cv.uint16_t,
}
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (frame := config.get(CONF_FRAME)) is not None:
template_ = await cg.templatable(frame, args, cg.uint16)
cg.add(var.set_frame(template_))
return var
async def setup_animation(config: ConfigType) -> None:
(
prog_arr,
width,
height,
image_type,
trans_value,
frame_count,
) = await write_image(config, all_frames=True)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
frame_count,
image_type,
trans_value,
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
to_code = setup_animation
+26 -5
View File
@@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
def _register_provisioning_source(config: ConfigType) -> ConfigType:
"""Register the API as a provisioning source when encryption is enabled.
With no ``key`` the device boots unprovisioned and is set up on first
connection; a YAML ``key`` means it is born provisioned. Either way the API
drives the provisioning manager, so it counts as a source for `provisioning:`.
A hardcoded ``key`` is reported so `provisioning:` can warn about it.
"""
if (encryption := config.get(CONF_ENCRYPTION)) is not None:
from esphome.components import provisioning
provisioning.register_source("api")
if CONF_KEY in encryption:
provisioning.report_hardcoded_credentials("api")
return config
def validate_encryption_key(value):
value = cv.string_strict(value)
try:
@@ -300,7 +317,7 @@ CONFIG_SCHEMA = cv.All(
CONF_LISTEN_BACKLOG,
esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets
esp32=4, # More RAM (520KB), BSD sockets
rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
bk72xx=4, # Moderate RAM, BSD-style sockets
rtl87xx=4, # Moderate RAM, BSD-style sockets
host=4, # Abundant resources
@@ -311,7 +328,7 @@ CONFIG_SCHEMA = cv.All(
CONF_MAX_CONNECTIONS,
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
esp32=5, # 520KB RAM available
rp2040=4, # 264KB RAM but LWIP constraints
rp2=4, # 264KB RAM but LWIP constraints
bk72xx=5, # Moderate RAM
rtl87xx=5, # Moderate RAM
host=8, # Abundant resources
@@ -326,7 +343,7 @@ CONFIG_SCHEMA = cv.All(
CONF_MAX_SEND_QUEUE,
esp8266=4, # Limited RAM, need to fail fast
esp32=8, # More RAM, can buffer more
rp2040=8, # Moderate RAM
rp2=8, # Moderate RAM
bk72xx=8, # Moderate RAM
nrf52=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
@@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
_register_provisioning_source,
)
@@ -470,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
# This will allow a plaintext client to provide a noise key,
# send it to the device, and then switch to noise.
# Until a key is set, the device accepts both Noise connections
# using the well-known all-zeros PSK (preferred: the key travels
# encrypted, protecting against passive sniffing) and plaintext
# connections (deprecated, remove after 2027.2.0) so a client can
# provide a noise key and the device then switches to noise only.
# The key will be saved in flash and used for future connections
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
+19
View File
@@ -158,6 +158,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
// Reason a party is requesting the connection be closed.
enum DisconnectReason {
// No specific reason / not provided (default for older peers).
DISCONNECT_REASON_UNSPECIFIED = 0;
// The device's provisioning window has expired. The device must be reset
// (power-cycled) to reopen the provisioning window before it will accept a
// connection again.
DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
}
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -166,6 +176,10 @@ message DisconnectRequest {
option (no_delay) = true;
// Do not close the connection before the acknowledgement arrives
// Optional reason the connection is being closed. Older peers that do not
// send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
DisconnectReason reason = 1;
}
message DisconnectResponse {
@@ -296,6 +310,11 @@ message DeviceInfoResponse {
// Serial proxy instance metadata
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
message ListEntitiesRequest {
+86 -30
View File
@@ -25,6 +25,9 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
@@ -195,6 +198,29 @@ APIConnection::~APIConnection() {
#endif
}
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
void APIConnection::upgrade_helper_to_noise_() {
// The client opened with a Noise hello while this device has no encryption
// key set. Replace the plaintext helper with a Noise helper so the key can
// be provisioned over an encrypted channel: the noise context PSK is all
// zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
// exchange, so a passive listener cannot read the session. A publicly known
// PSK authenticates nobody; this protects against sniffing only.
auto *plaintext = static_cast<APIPlaintextFrameHelper *>(this->helper_.get());
uint8_t header[3];
uint8_t header_len = plaintext->get_consumed_header(header);
auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
// Carry over the peername-based client name (Hello has not arrived yet)
const char *name = plaintext->get_client_name();
noise->set_client_name(name, strlen(name));
this->helper_.reset(noise); // destroys the plaintext helper
APIError err = noise->init_from_handoff(header, header_len);
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
}
}
#endif // USE_API_NOISE && USE_API_PLAINTEXT
void APIConnection::destroy_active_iterator_() {
switch (this->active_iterator_) {
case ActiveIterator::LIST_ENTITIES:
@@ -253,6 +279,15 @@ void APIConnection::loop() {
// No more data available
break;
} else if (err != APIError::OK) {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Checked inside the error branch to keep the hot err == OK path
// free of it; this can only fire on the first bytes of a plaintext
// helper on an unprovisioned device
if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
this->upgrade_helper_to_noise_();
return;
}
#endif
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return;
} else {
@@ -759,6 +794,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
// Current feature flags and other supported parameters
msg.feature_flags = traits.get_feature_flags();
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
msg.supported_modes = &traits.get_supported_modes();
msg.visual_min_temperature = traits.get_visual_min_temperature();
msg.visual_max_temperature = traits.get_visual_max_temperature();
@@ -1293,7 +1329,8 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
}
}
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
bool APIConnection::send_voice_assistant_get_configuration_response_(
const VoiceAssistantConfigurationRequest & /*msg*/) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
@@ -1313,22 +1350,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice
}
}
// Filter external wake words
for (auto &wake_word : msg.external_wake_words) {
if (wake_word.model_type != "micro") {
// microWakeWord only
continue;
}
resp.available_wake_words.emplace_back();
auto &resp_wake_word = resp.available_wake_words.back();
resp_wake_word.id = StringRef(wake_word.id);
resp_wake_word.wake_word = StringRef(wake_word.wake_word);
for (const auto &lang : wake_word.trained_languages) {
resp_wake_word.trained_languages.push_back(lang);
}
}
resp.active_wake_words = &config.active_wake_words;
resp.max_active_wake_words = config.max_active_wake_words;
return this->send_message(resp);
@@ -1348,7 +1369,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
#ifdef USE_ZWAVE_PROXY
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
@@ -1433,6 +1454,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
msg.target_temperature_step = traits.get_target_temperature_step();
msg.supported_modes = &traits.get_supported_modes();
msg.supported_features = traits.get_feature_flags();
msg.temperature_unit = static_cast<enums::TemperatureUnit>(traits.get_temperature_unit());
return fill_and_encode_entity_info(wh, msg, conn, remaining_size);
}
@@ -1521,8 +1543,8 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure
static_cast<uint32_t>(proxies.size()));
return;
}
proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity), msg.stop_bits,
msg.data_size);
proxies[msg.instance]->configure(this, msg.baudrate, msg.flow_control, static_cast<uint8_t>(msg.parity),
msg.stop_bits, msg.data_size);
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1531,7 +1553,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len);
}
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
@@ -1540,7 +1562,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
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) {
@@ -1711,12 +1733,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
// TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
if (!this->client_supports_api_version(1, 14)) {
ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
this->client_api_version_major_, this->client_api_version_minor_);
}
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
@@ -1724,6 +1740,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
// The provisioning window has closed without the device being provisioned.
// Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
this->send_message(resp);
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req);
}
#endif
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
@@ -1759,7 +1788,7 @@ bool APIConnection::send_device_info_response_() {
// Manufacturer string - define once, handle ESP8266 PROGMEM separately
#if defined(USE_ESP8266) || defined(USE_ESP32)
#define ESPHOME_MANUFACTURER "Espressif"
#elif defined(USE_RP2040)
#elif defined(USE_RP2)
#define ESPHOME_MANUFACTURER "Raspberry Pi"
#elif defined(USE_BK72XX)
#define ESPHOME_MANUFACTURER "Beken"
@@ -1844,6 +1873,12 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
// No key from YAML: while no key is set, the key can be provisioned over a
// zero-PSK Noise connection. Gated on the YAML define (not the plaintext
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
@@ -1874,7 +1909,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
this->on_fatal_error();
}
}
void APIConnection::on_disconnect_request() {
void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
// The reason is informational when a client disconnects us; we always ack and close.
if (!this->send_disconnect_response_()) {
this->on_fatal_error();
}
@@ -2002,6 +2038,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
#ifdef USE_PROVISIONING
// Refuse to set a key once the provisioning window has closed (defense in depth;
// such connections are already rejected at hello).
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
return this->send_message(resp);
}
#endif
psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
@@ -2011,10 +2056,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (APINoiseContext::is_all_zeros(psk)) {
// Accepting the reserved provisioning PSK would report success without
// enabling encryption (or silently clear an existing key)
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
} else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key");
} else {
resp.success = true;
#ifdef USE_API_PLAINTEXT
if (this->helper_->frame_footer_size() == 0) {
// Plaintext transport has no frame footer; Noise always has the MAC footer.
// Remove after 2027.2.0 together with plaintext support on keyless devices.
ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
}
#endif
}
return this->send_message(resp);
+16 -7
View File
@@ -18,8 +18,8 @@
#ifdef USE_ESP32_CRASH_HANDLER
#include "esphome/components/esp32/crash_handler.h"
#endif
#ifdef USE_RP2040_CRASH_HANDLER
#include "esphome/components/rp2040/crash_handler.h"
#ifdef USE_RP2_CRASH_HANDLER
#include "esphome/components/rp2/crash_handler.h"
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
#include "esphome/components/esp8266/crash_handler.h"
@@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase {
#endif
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
#ifdef USE_API_HOMEASSISTANT_SERVICES
void send_homeassistant_action(const HomeassistantActionRequest &call) {
// Returns whether this client has subscribed to Home Assistant actions; the message
// is only handed to the send path when subscribed. A true return does not guarantee
// delivery - it lets the caller warn when no connected client has the subscription.
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return;
return false;
this->send_message(call);
return true;
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
@@ -259,7 +263,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_get_time_response(const GetTimeResponse &value);
#endif
void on_hello_request(const HelloRequest &msg);
void on_disconnect_request();
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
@@ -279,8 +283,8 @@ class APIConnection final : public APIServerConnectionBase {
esp32::crash_handler_log();
esp32::crash_handler_clear();
#endif
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_log();
#ifdef USE_RP2_CRASH_HANDLER
rp2::crash_handler_log();
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
esp8266::crash_handler_log();
@@ -626,6 +630,11 @@ class APIConnection final : public APIServerConnectionBase {
void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_();
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Swap the plaintext helper for a Noise helper after the client opened
// with a Noise hello on an unprovisioned device (zero-PSK provisioning).
void upgrade_helper_to_noise_();
#endif
#ifdef USE_CAMERA
std::unique_ptr<camera::CameraImageReader> image_reader_;
#endif
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
}
#endif
// PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
// any logging can happen, so it intentionally has no entry here.
return LOG_STR("UNKNOWN");
}
+11
View File
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
HANDSHAKESTATE_SPLIT_FAILED = 1020,
BAD_HANDSHAKE_ERROR_BYTE = 1021,
#endif
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Not an error: an unprovisioned device received a Noise client hello on a
// plaintext connection; the caller must hand the socket off to a Noise helper.
PROTOCOL_SWITCH_TO_NOISE = 1023,
#endif
};
const LogString *api_error_to_logstr(APIError err);
@@ -200,6 +205,12 @@ class APIFrameHelper {
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Move the socket out of this helper so a replacement helper can take it
// over (plaintext to Noise handoff on unprovisioned devices). The drained
// helper must be destroyed right after.
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
#endif
// Release excess memory from internal buffers after initial sync
void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress.
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
if (err != APIError::OK) {
return err;
}
// Seed the header bytes the plaintext helper consumed before detecting the
// Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
std::memcpy(this->rx_header_buf_, header, header_len);
this->rx_header_buf_len_ = header_len;
// Pump the handshake without gating on socket_->ready(): on LWIP the
// plaintext helper's partial read can drain rcvevent while the rest of the
// client hello sits in the lastdata cache, so ready() may report false even
// though data is available.
return this->pump_handshake_();
}
#endif // USE_API_PLAINTEXT
/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
/// and resume on the next loop().
APIError APINoiseFrameHelper::pump_handshake_() {
while (this->state_ != State::DATA) {
APIError err = this->state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
if (err != APIError::OK) {
return err;
}
}
return APIError::OK;
}
// Helper for handling handshake frame errors
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
if (aerr == APIError::BAD_INDICATOR) {
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
// that must follow reads, deadlocking the handshake. state_action() will return
// WOULD_BLOCK when no more data is available to read.
bool socket_ready = this->socket_->ready();
while (state_ != State::DATA && socket_ready) {
APIError err = state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
// Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
// ready() returns false once the rx buffer is consumed. Re-checking each
// iteration would block handshake writes that must follow reads,
// deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
// no more data is available to read.
if (state_ != State::DATA && this->socket_->ready()) {
APIError err = this->pump_handshake_();
if (err != APIError::OK) {
return err;
}
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
}
~APINoiseFrameHelper() override;
APIError init() override;
#ifdef USE_API_PLAINTEXT
// Take over a connection whose first bytes were consumed by a plaintext
// helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
protected:
APIError pump_handshake_();
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// If this was the first read, validate the indicator byte
if (rx_header_buf_pos_ == 0 && received > 0) {
if (rx_header_buf_[0] != 0x00) {
#ifdef USE_API_NOISE
// Dual build (encryption supported but no key set): a 0x01 first byte
// is a Noise client hello. Hand the connection off to a Noise helper
// running the all-zeros provisioning PSK so the encryption key can be
// set without crossing the wire in plaintext. Preserve the bytes we
// already consumed; they are the start of the Noise 3-byte header.
if (rx_header_buf_[0] == 0x01) {
rx_header_buf_pos_ = static_cast<uint8_t>(received);
return APIError::PROTOCOL_SWITCH_TO_NOISE;
}
#endif
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
#ifdef USE_API_NOISE
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
// header bytes already consumed from the socket (at most 3, the size of the
// Noise fixed header) so the replacement Noise helper can be seeded with them.
uint8_t get_consumed_header(uint8_t out[3]) const {
memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
return this->rx_header_buf_pos_;
}
#endif
protected:
APIError try_read_frame_();
+12 -5
View File
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
class APINoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
bool has_psk = false;
for (auto i : psk) {
has_psk |= i;
}
this->has_psk_ = has_psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
+19 -2
View File
@@ -12,6 +12,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
// socket->write() can re-enter this function: a log message emitted from an
// lwip callback during the write goes out over the API and lands back in the
// frame helper's write/drain path. If a nested drain ran here it would send
// and free the entry the outer drain is still holding, causing a double free.
// Report "no progress" instead; the outer drain keeps draining, and the
// nested send is enqueued behind the existing backlog.
if (this->draining_)
return 0;
// RAII so the flag is cleared on every return path
struct DrainGuard {
explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
~DrainGuard() { this->flag_ = false; }
bool &flag_;
} guard(this->draining_);
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
@@ -29,11 +45,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
return sent;
}
// Entry fully sent — free it and advance
Entry::destroy(front);
// Entry fully sent — unlink it before freeing so a freed pointer is never
// reachable from the queue
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--;
Entry::destroy(front);
}
return 0; // All drained
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
uint8_t head_{0};
uint8_t tail_{0};
uint8_t count_{0};
// Guards against re-entrant drains: socket->write() can re-enter the API
// send path (e.g. a log message emitted from an lwip callback), and a nested
// drain would free the entry the outer drain is still holding.
bool draining_{false};
};
} // namespace esphome::api
+26
View File
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
size += 2 + this->name.size();
return size;
}
bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->reason = static_cast<enums::DisconnectReason>(value);
break;
default:
return false;
}
return true;
}
uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->reason));
return pos;
}
uint32_t DisconnectRequest::calculate_size() const {
uint32_t size = 0;
size += this->reason ? 2 : 0;
return size;
}
#ifdef USE_AREAS
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
@@ -150,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
}
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
return pos;
}
@@ -212,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(2, it.calculate_size());
}
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
return size;
}
+14 -3
View File
@@ -11,6 +11,10 @@ namespace esphome::api {
namespace enums {
enum DisconnectReason : uint32_t {
DISCONNECT_REASON_UNSPECIFIED = 0,
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
protected:
};
class DisconnectRequest final : public ProtoMessage {
class DisconnectRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 5;
static constexpr uint8_t ESTIMATED_SIZE = 0;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
#endif
enums::DisconnectReason reason{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class DisconnectResponse final : public ProtoMessage {
public:
@@ -525,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 309;
static constexpr uint16_t ESTIMATED_SIZE = 312;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -580,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
+15 -1
View File
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
switch (value) {
case enums::DISCONNECT_REASON_UNSPECIFIED:
return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
dump_field(out, ESPHOME_PSTR("reason"), static_cast<enums::DisconnectReason>(this->reason));
return out.c_str();
}
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
@@ -971,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
it.dump_to(out);
out.append("\n");
}
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
return out.c_str();
}
+4 -2
View File
@@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
case DisconnectRequest::MESSAGE_TYPE: {
DisconnectRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_disconnect_request"));
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
#endif
this->on_disconnect_request();
this->on_disconnect_request(msg);
break;
}
case DisconnectResponse::MESSAGE_TYPE: {
+1 -1
View File
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
void on_hello_request(const HelloRequest &value){};
void on_disconnect_request(){};
void on_disconnect_request(const DisconnectRequest &value){};
void on_disconnect_response(){};
void on_ping_request(){};
void on_ping_response(){};
+62 -12
View File
@@ -107,8 +107,30 @@ void APIServer::setup() {
// Initialize last_connected_ for reboot timeout tracking
this->last_connected_ = App.get_loop_component_start_time();
// Set warning status if reboot timeout is enabled
if (this->reboot_timeout_ != 0) {
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Register with the provisioning manager (provisioning:) as a source and
// report our current state (provisioned == an encryption key is set). When the
// window closes, disconnect any client still attempting to provision so it learns
// the reason. The manager owns the timeout, window state and on_timeout automation.
if (provisioning::global_provisioning_manager != nullptr) {
this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
this->noise_ctx_.has_psk());
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
for (auto &c : this->active_clients()) {
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
// Best-effort: if the send buffer is full the reason is dropped, but the
// client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close.
c->send_message(req);
}
});
}
#endif
// Set warning status if reboot timeout is enabled (suppressed while provisioning
// is pending so the device waits to be onboarded instead of rebooting).
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
}
@@ -121,8 +143,10 @@ void APIServer::loop() {
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time)
if (this->reboot_timeout_ != 0) {
// (cancelled scheduler items sit in heap memory until their scheduled time).
// Suppressed while a provisioning window is pending so the device waits to be
// onboarded / reset instead of rebooting itself; resumes once provisioned.
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "No clients; rebooting");
@@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) {
this->clients_[last_index].reset();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
// (suppressed while provisioning is pending - see loop()).
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -240,12 +265,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
}
void APIServer::dump_config() {
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Server:\n"
" Address: %s:%u\n"
" Listen backlog: %u\n"
" Max connections: %u",
network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
#ifdef USE_API_NOISE
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
if (!this->noise_ctx_.has_psk()) {
@@ -400,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
bool has_subscriber = false;
for (auto &client : this->active_clients()) {
client->send_homeassistant_action(call);
has_subscriber |= client->send_homeassistant_action(call);
}
if (!has_subscriber) {
// Home Assistant subscribes to actions shortly *after* authenticating, so actions
// fired right at connection time (on_client_connected, on_time_sync, ...) can
// arrive before the subscription and are lost - warn instead of failing silently.
ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
}
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -571,8 +605,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}
SavedNoisePsk new_saved_psk{psk};
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The device now has a key; report provisioned so the provisioning window is
// satisfied and the reboot timeout resumes normal operation.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
}
#endif
return result;
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
@@ -583,8 +625,16 @@ bool APIServer::clear_noise_psk(bool make_active) {
return false;
#else
SavedNoisePsk empty_psk{};
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The key was cleared; report unprovisioned so a subsequent reboot reopens the
// provisioning window.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
}
#endif
return result;
#endif
}
#endif
+20 -1
View File
@@ -14,6 +14,9 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -255,6 +258,19 @@ class APIServer final : public Component,
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
#ifdef USE_PROVISIONING
// True while a configured provisioning window is still pending (the device is
// unprovisioned). Suppresses the reboot timeout and its warning so the device is
// not auto-rebooted while waiting to be provisioned. False when no provisioning
// window is configured.
bool provisioning_pending_() const {
return provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
}
#else
bool provisioning_pending_() const { return false; }
#endif
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
bool make_active);
@@ -332,7 +348,10 @@ class APIServer final : public Component,
uint8_t listen_backlog_{4};
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
// 7 bytes used, 1 byte padding
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
+17 -11
View File
@@ -18,7 +18,7 @@ with warnings.catch_warnings():
import contextlib
from esphome.const import CONF_KEY, CONF_PORT, __version__
from esphome.core import CORE, EsphomeError
from esphome.core import CORE
from esphome.util import safe_print
from . import CONF_ENCRYPTION
@@ -36,15 +36,17 @@ class _LogLineProcessor:
"""Feeds incoming log lines to the stack-trace decoder.
Two responsibilities beyond just calling the decoder:
1. Catch EsphomeError. on_log runs inside an asyncio protocol
callback; if an exception escapes, the loop tears the transport
down with "Fatal error: protocol.data_received() call failed."
and ReconnectLogic immediately reconnects, the device replays
the same crash trace, and we loop forever.
2. Disable decoding after the first failure. _decode_pc shells out
to PlatformIO via _run_idedata, which is expensive; a single
crash dump can contain many PC/BT lines and we don't want to
retry the failing subprocess for each one.
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:
@@ -61,12 +63,13 @@ class _LogLineProcessor:
self.backtrace_state = self._platform_handler(
self._config, raw_line, self.backtrace_state
)
except EsphomeError as exc:
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.",
@@ -152,6 +155,9 @@ async def async_run_logs(
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()
+1
View File
@@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType")
CONF_AQI = "aqi"
CONF_CALCULATION_TYPE = "calculation_type"
CONF_EXTENDED_RANGE = "extended_range"
AQI_CALCULATION_TYPE = {
"CAQI": AQICalculatorType.CAQI_TYPE,
@@ -6,7 +6,7 @@ namespace esphome::aqi {
class AbstractAQICalculator {
public:
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0;
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0;
};
} // namespace esphome::aqi
+20 -10
View File
@@ -11,10 +11,12 @@ namespace esphome::aqi {
class AQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range);
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
// extended_range lets the index run past the standard maximum, so clamp to the sensor's range.
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
return static_cast<uint16_t>(std::lround(aqi));
}
@@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator {
{35.5f, 55.5f},
{55.5f, 125.5f},
{125.5f, 225.5f},
{225.5f, std::numeric_limits<float>::max()}
{225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3
// clang-format on
};
@@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator {
{155.0f, 255.0f},
{255.0f, 355.0f},
{355.0f, 425.0f},
{425.0f, std::numeric_limits<float>::max()}
{425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band)
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) {
int grid_index = get_grid_index(value, array);
if (grid_index == -1) {
return -1.0f;
@@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator {
float conc_lo = array[grid_index][0];
float conc_hi = array[grid_index][1];
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
// Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we
// clamp to the standard maximum; with extended_range we keep the extrapolated "over-range"
// value so heavy pollution reports numbers beyond what the standard defines.
if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) {
return aqi_hi;
}
return index;
}
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
// The top band is open-ended: any value at or above its lower breakpoint falls into it,
// and calculate_index() decides whether to clamp or extrapolate.
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
if (in_range) {
return i;
}
+2 -1
View File
@@ -24,6 +24,7 @@ void AQISensor::setup() {
void AQISensor::dump_config() {
ESP_LOGCONFIG(TAG, "AQI Sensor:");
ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI");
ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled");
if (this->pm_2_5_sensor_ != nullptr) {
ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str());
}
@@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() {
return;
}
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_);
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_);
this->publish_state(aqi);
}
+2
View File
@@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component {
void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; }
void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; }
void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; }
void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; }
protected:
void calculate_aqi_();
@@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component {
sensor::Sensor *pm_2_5_sensor_{nullptr};
sensor::Sensor *pm_10_0_sensor_{nullptr};
AQICalculatorType aqi_calc_type_{AQI_TYPE};
bool extended_range_{false};
AQICalculatorFactory aqi_calculator_factory_;
float pm_2_5_value_{NAN};
+13 -10
View File
@@ -9,25 +9,28 @@ namespace esphome::aqi {
class CAQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
// The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We
// therefore always extrapolate the top band past 100 without limit, so the extended_range flag
// (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored.
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
return static_cast<uint16_t>(std::lround(aqi));
}
protected:
static constexpr int NUM_LEVELS = 5;
static constexpr int NUM_LEVELS = 4;
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
// clang-format off
{0.0f, 15.1f},
{15.1f, 30.1f},
{30.1f, 55.1f},
{55.1f, 110.1f},
{110.1f, std::numeric_limits<float>::max()}
{55.1f, 110.1f}
// clang-format on
};
@@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator {
{0.0f, 25.1f},
{25.1f, 50.1f},
{50.1f, 90.1f},
{90.1f, 180.1f},
{180.1f, std::numeric_limits<float>::max()}
{90.1f, 180.1f}
// clang-format on
};
@@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator {
float conc_lo = array[grid_index][0];
float conc_hi = array[grid_index][1];
// The top band is open-ended (see get_grid_index), so for concentrations above the last
// breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class.
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
}
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
// The top band is open-ended: any value at or above its lower breakpoint falls into it.
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
if (in_range) {
return i;
}
+17 -3
View File
@@ -8,14 +8,25 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
)
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns
CODEOWNERS = ["@jasstrong"]
DEPENDENCIES = ["sensor"]
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
def _validate_extended_range(config):
if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI":
raise cv.Invalid(
f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. "
"CAQI has no maximum value by specification, so it is always reported unbounded.",
[CONF_EXTENDED_RANGE],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
AQISensor,
accuracy_decimals=0,
@@ -29,9 +40,11 @@ CONFIG_SCHEMA = (
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
AQI_CALCULATION_TYPE, upper=True
),
cv.Optional(CONF_EXTENDED_RANGE): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
_validate_extended_range,
)
@@ -46,3 +59,4 @@ async def to_code(config):
cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor))
cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE]))
cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False)))
+1 -5
View File
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
uint8_t value;
if (write(&reg, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Writing register failed!");
return 0;
}
if (read(&value, 1) != i2c::ERROR_OK) {
if (!this->read_byte(reg, &value)) {
ESP_LOGW(TAG, "Reading register failed!");
return 0;
}
+2 -2
View File
@@ -5,7 +5,7 @@ from esphome.const import (
CONF_CLEAR,
CONF_GAIN,
CONF_ID,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_EMPTY,
ICON_BRIGHTNESS_5,
STATE_CLASS_MEASUREMENT,
)
@@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
)
+3 -3
View File
@@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]:
if (
not CORE.is_esp32
and not CORE.is_esp8266
and not CORE.is_rp2040
and not CORE.is_rp2
and not CORE.is_libretiny
):
return ["socket"]
@@ -37,7 +37,7 @@ async def to_code(config):
elif CORE.is_esp8266:
# https://github.com/ESP32Async/ESPAsyncTCP
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")
elif CORE.is_rp2040:
elif CORE.is_rp2:
# https://github.com/ayushsharma82/RPAsyncTCP
# RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better
# ESPAsyncWebServer compatibility
@@ -47,6 +47,6 @@ async def to_code(config):
def FILTER_SOURCE_FILES() -> list[str]:
# Exclude socket implementation for platforms that use AsyncTCP libraries
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny:
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny:
return ["async_tcp_socket.cpp"]
return []
+1 -1
View File
@@ -7,7 +7,7 @@
#elif defined(USE_ESP8266)
// Use ESPAsyncTCP library for ESP8266 (always Arduino)
#include <ESPAsyncTCP.h>
#elif defined(USE_RP2040)
#elif defined(USE_RP2)
// Use RPAsyncTCP library for RP2040
#include <RPAsyncTCP.h>
#else
@@ -1,6 +1,6 @@
#include "async_tcp_socket.h"
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \
(defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS))
#include "esphome/components/network/util.h"
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \
(defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS))
#include "esphome/components/socket/socket.h"
@@ -65,12 +65,11 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::S
return {};
}
static uint8_t last_frame_count = 0;
if (last_frame_count == raw[12]) {
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count);
if (this->last_frame_count_ == raw[12]) {
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_);
return {};
}
last_frame_count = raw[12];
this->last_frame_count_ = raw[12];
return result;
}
@@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
sensor::Sensor *battery_voltage_{nullptr};
sensor::Sensor *signal_strength_{nullptr};
uint8_t last_frame_count_{0};
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
bool report_results_(const optional<ParseResult> &result, const char *address);
+3 -1
View File
@@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type == "wav":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
elif file_type in ("mp3", "mpeg", "mpga"):
elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"):
# With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2".
# Treat those labels as MP3 so we still pick the MP3 decoder.
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
elif file_type == "flac":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
@@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip {
static const char *const TAG = "beken_spi_led_strip";
struct spi_data_t {
struct SpiData {
SemaphoreHandle_t dma_tx_semaphore;
volatile bool tx_in_progress;
bool first_run;
};
static spi_data_t *spi_data = nullptr;
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void set_spi_ctrl_register(unsigned long bit, bool val) {
static void set_spi_ctrl_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CTRL);
if (val == 0) {
value &= ~bit;
@@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) {
REG_WRITE(SPI_CTRL, value);
}
static void set_spi_config_register(unsigned long bit, bool val) {
static void set_spi_config_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CONFIG);
if (val == 0) {
value &= ~bit;
@@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) {
void spi_dma_tx_enable(bool enable) {
GDMA_CFG_ST en_cfg;
set_spi_config_register(SPI_TX_EN, enable ? 1 : 0);
set_spi_config_register(SPI_TX_EN, enable);
en_cfg.channel = SPI_TX_DMA_CHANNEL;
en_cfg.param = enable ? 1 : 0;
sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg);
@@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) {
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
param |= (div << SPI_CKR_POSI);
REG_WRITE(SPI_CTRL, param);
ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div);
ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div);
}
void spi_dma_tx_finish_callback(unsigned int param) {
spi_data->tx_in_progress = false;
xSemaphoreGive(spi_data->dma_tx_semaphore);
spi_dma_tx_enable(0);
spi_dma_tx_enable(false);
}
void BekenSPILEDStripLightOutput::setup() {
@@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() {
return;
}
spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t));
spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc)
if (spi_data == nullptr) {
ESP_LOGE(TAG, "Cannot allocate spi_data!");
this->mark_failed();
@@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() {
spi_data->first_run = true;
set_spi_ctrl_register(MSTEN, 0);
set_spi_ctrl_register(BIT_WDTH, 0);
set_spi_ctrl_register(MSTEN, false);
set_spi_ctrl_register(BIT_WDTH, false);
spi_set_clock(this->spi_frequency_);
set_spi_ctrl_register(CKPOL, 0);
set_spi_ctrl_register(CKPHA, 0);
set_spi_ctrl_register(MSTEN, 1);
set_spi_ctrl_register(SPIEN, 1);
set_spi_ctrl_register(CKPOL, false);
set_spi_ctrl_register(CKPHA, false);
set_spi_ctrl_register(MSTEN, true);
set_spi_ctrl_register(SPIEN, true);
set_spi_ctrl_register(TXINT_EN, 0);
set_spi_ctrl_register(RXINT_EN, 0);
set_spi_config_register(SPI_TX_FINISH_EN, 1);
set_spi_config_register(SPI_RX_FINISH_EN, 1);
set_spi_ctrl_register(RXOVR_EN, 0);
set_spi_ctrl_register(TXOVR_EN, 0);
set_spi_ctrl_register(TXINT_EN, false);
set_spi_ctrl_register(RXINT_EN, false);
set_spi_config_register(SPI_TX_FINISH_EN, true);
set_spi_config_register(SPI_RX_FINISH_EN, true);
set_spi_ctrl_register(RXOVR_EN, false);
set_spi_ctrl_register(TXOVR_EN, false);
value = REG_READ(SPI_CTRL);
value &= ~CTRL_NSSMD_3;
@@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() {
value = GFUNC_MODE_SPI_DMA;
sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value);
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0);
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false);
GDMA_CFG_ST en_cfg;
GDMACFG_TPYES_ST init_cfg;
@@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() {
init_cfg.dstptr_incr = 0;
init_cfg.srcptr_incr = 1;
init_cfg.src_start_addr = this->dma_buf_;
init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO
init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO
init_cfg.channel = SPI_TX_DMA_CHANNEL;
init_cfg.prio = 0; // 10
init_cfg.u.type4.src_loop_start_addr = this->dma_buf_;
@@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() {
en_cfg.param = 0;
sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg);
spi_dma_tx_enable(0);
spi_dma_tx_enable(false);
value = REG_READ(SPI_CONFIG);
value &= ~(0xFFF << 8);
@@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
// protect from refreshing too often
uint32_t now = micros();
if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) {
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
(now - this->last_refresh_) < *this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
return;
@@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
spi_data->first_run = false;
spi_dma_tx_enable(1);
spi_dma_tx_enable(true);
this->status_clear_warning();
}
@@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
" RGB Order: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
rgb_order, *this->max_refresh_rate_, this->num_leds_);
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
+3 -1
View File
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Exclusive(
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
): cv.boolean,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
+94
View File
@@ -0,0 +1,94 @@
"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips.
The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
with a clear #error.
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
prebuilt libble_<chip>.a per SoC). This component only calls into it via the
public ble_api.h.
"""
import logging
import esphome.codegen as cg
from esphome.components import libretiny
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
from esphome.types import ConfigType
DEPENDENCIES = ["bk72xx"]
CODEOWNERS = ["@Bl00d-B0b"]
_LOGGER = logging.getLogger(__name__)
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(BK72xxBLE),
# Default off: on the single-core BK72xx, bringing the BLE stack up during
# boot competes with the WiFi connection handshake. Consumers enable the
# stack lazily on first use (e.g. the tracker's first scan start).
cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
# Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value
# is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0
# on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this
# SAME key — and add_platformio_option appends list values, it never replaces.
# The platform therefore skips its disable when this component is configured,
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
# bundled 3.0.33 has an older BLE header/library layout — and with
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
# (the reason the libretiny platform sets =0 when BLE is unused), so a
# mismatched BDK can crash the device before WiFi comes up regardless of
# enable_on_boot. Pinning here makes a plain config build against the
# validated BDK without any manual platformio_options.
_LOGGER.warning(
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
"default: the default's older BLE layout can crash the device at boot when "
"BLE is compiled in"
)
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
# which path is available so it doesn't reference a missing symbol.
family = libretiny.get_libretiny_family()
if family == FAMILY_BK7231N:
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
elif family == FAMILY_BK7238:
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
# warn loudly: BK7238 is accepted but not hardware-verified and may be
# WiFi-unstable with BLE on.
_LOGGER.warning(
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
"hang on this family and is not yet hardware-verified. Expect possible "
"instability."
)
cg.add_define("USE_BK72XX_BLE")
@@ -0,0 +1,292 @@
// bk72xx_ble.cpp
//
// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx
// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that
// talks to the Beken BDK BLE stack:
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
// - the controller BLE address,
// - the raw controller scan primitives (bk_ble_scan_start/stop),
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains,
// dispatches on the main task and returns reports to the pool — the same
// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at
// steady state.
// Consumers contain no SDK calls of their own.
//
// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny
// beken-72xx builder itself (prebuilt libble_<chip>.a + ble_5_x sources, gated
// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only
// calls into it via the public ble_api.h — no framework patch is required.
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
#ifdef USE_BK72XX_BLE
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h" // get_mac_address_raw()
#include "esphome/core/log.h"
// ---------------------------------------------------------------------------
// SDK-capability gate (not a chip allowlist).
// This component drives the Beken BLE *5.x* controller via its public API,
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
// itself so any BLE-5.x Beken chip — present or future — is supported without a
// hard-coded list, and a non-5.x build fails here with a clear message instead
// of a cryptic "ble_api.h: No such file or directory".
// ---------------------------------------------------------------------------
#if defined(CLANG_TIDY)
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
// accurate to analyze the SDK calls against — skip the file under analysis.
#define BK72XX_BLE_NO_SDK
#elif !__has_include("ble_api.h")
#error \
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
#endif
#ifndef BK72XX_BLE_NO_SDK
// ---------------------------------------------------------------------------
// Beken BDK BLE 5.x SDK — public API.
// Exposed on the include path by the LibreTiny beken-72xx builder
// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
// ---------------------------------------------------------------------------
extern "C" {
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb,
// app_ble_get_idle_actv_idx_handle, struct scan_param,
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
#include "common_bt_defines.h" // struct bd_addr
// The controller's public BLE address, populated by the BDK during ble_entry().
// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the
// address is derived from the WiFi MAC instead (matching the BDK's own fallback).
extern struct bd_addr common_default_bdaddr;
#endif
// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so
// declare it here.
void ble_entry(void);
}
namespace esphome::bk72xx_ble {
static const char *const TAG = "bk72xx_ble";
// The BDK notice callback is a plain C function pointer with no user argument,
// so it reaches the (single) component instance through a file-static pointer.
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
// ---------------------------------------------------------------------------
// BLE notice callback — runs in the BDK BLE task context.
// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice
// carrying a recv_adv_t. Copy it into the queue and return; all dispatch
// happens in loop() on the main task.
// ---------------------------------------------------------------------------
static void ble_notice_callback(ble_notice_t notice, void *param) {
if (s_ble == nullptr || param == nullptr)
return;
if (notice != BLE_5_REPORT_ADV)
return;
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for
// a signed dBm value packed in a uint8_t).
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data,
info->data_len);
}
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
BLEScanReport *report = this->report_pool_.allocate();
if (report == nullptr) {
// Pool exhausted — the queue is full; count and drop.
this->report_queue_.increment_dropped_count();
return;
}
memcpy(report->mac, mac, 6);
report->rssi = rssi;
report->addr_type = addr_type;
report->data_len =
(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);
// Cannot fail: the pool is sized to the queue capacity.
this->report_queue_.push(report);
}
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void BK72xxBLE::setup() {
s_ble = this;
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
// the stack is up (it is re-read once ble_entry() has run).
this->resolve_mac_();
if (this->enable_on_boot_) {
this->enable();
}
}
// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK
// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order).
float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
void BK72xxBLE::enable() {
if (this->state_ != BLEComponentState::STATE_OFF)
return;
this->state_ = BLEComponentState::ENABLING;
// One-time BLE stack init: register the notice callback, then bring up the
// BDK BLE stack. The BDK has no teardown path — init happens at most once.
ble_set_notice_cb(ble_notice_callback);
ble_entry();
delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time
// Re-read the BLE MAC now that the controller is up (common_default_bdaddr is
// populated by ble_entry()); resolve_mac_() may have fallen back earlier.
this->resolve_mac_();
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
// Liveness heuristic (BK7231N): a healthy ble_entry() populates
// common_default_bdaddr during init, so all-zero after the settle delay
// suggests the stack did not come up. The BDK entry point returns void — no
// return code exists — so warn rather than fail: scan starts against a dead
// stack already fail cleanly downstream (no idle activity handle).
bool bdaddr_live = false;
for (uint8_t b : common_default_bdaddr.addr) {
if (b != 0) {
bdaddr_live = true;
break;
}
}
if (!bdaddr_live)
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
#endif
this->state_ = BLEComponentState::ACTIVE;
ESP_LOGD(TAG, "BLE stack initialised");
}
void BK72xxBLE::loop() {
// Drain the lock-free ring filled by the BLE task; all per-report work runs
// here on the main task, then the report returns to the pool.
BLEScanReport *report = this->report_queue_.pop();
if (report == nullptr)
return;
do {
for (auto *listener : this->scan_listeners_)
listener->on_scan_report(*report);
this->report_pool_.release(report);
} while ((report = this->report_queue_.pop()) != nullptr);
// Log dropped reports — only reachable when reports were processed; drops can
// only occur while the queue is full, and only this loop drains it.
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
if (dropped > 0)
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
}
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
for (int i = 0; i < 6; i++)
out[i] = this->ble_mac_[i];
}
void BK72xxBLE::dump_config() {
// ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the
// MSB-first order Home Assistant shows.
ESP_LOGCONFIG(TAG,
"BK72xx BLE:\n"
" MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n"
" Active: %s",
this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1],
this->ble_mac_[0], YESNO(this->is_active()));
}
// ---------------------------------------------------------------------------
// MAC resolution
// ---------------------------------------------------------------------------
void BK72xxBLE::resolve_mac_() {
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
// BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention)
// during ble_entry(). It may still be zero before the stack is up; if so, fall
// through to the WiFi-derived MAC below.
bool nonzero = false;
for (uint8_t b : common_default_bdaddr.addr) {
if (b != 0) {
nonzero = true;
break;
}
}
if (nonzero) {
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
return;
}
#endif
// Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other
// BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the
// Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte
// incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next
// byte), OUI unchanged. This reproduces the address the controller advertises with
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
// would carry differently.
uint8_t wifi_mac[6];
get_mac_address_raw(wifi_mac); // MSB-first
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
// Store LSB-first to match recv_adv_t adv_addr ordering.
for (int i = 0; i < 6; i++)
this->ble_mac_[i] = ble[5 - i];
}
// ---------------------------------------------------------------------------
// Controller scan primitives
// ---------------------------------------------------------------------------
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
if (!this->is_active())
this->enable();
if (this->scan_actv_idx_ != 0xFF) {
// Already scanning — stop first so this call cleanly restarts with the new
// parameters (the BDK cannot start a second scan on a busy activity).
this->scan_stop();
}
struct scan_param sp;
memset(&sp, 0, sizeof(sp));
sp.channel_map = 7; // advertising channels 37/38/39
sp.interval = interval;
sp.window = window;
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
if (this->scan_actv_idx_ == 0xFF) {
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
return false;
}
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr);
if (ret != ERR_SUCCESS) {
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
this->scan_actv_idx_ = 0xFF;
return false;
}
return true;
}
void BK72xxBLE::scan_stop() {
if (this->scan_actv_idx_ != 0xFF) {
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
this->scan_actv_idx_ = 0xFF;
}
}
} // namespace esphome::bk72xx_ble
#endif // BK72XX_BLE_NO_SDK
#endif // USE_BK72XX_BLE
@@ -0,0 +1,98 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BK72XX_BLE
#include "esphome/core/component.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/lock_free_queue.h"
#include <cstdint>
#include <vector>
namespace esphome::bk72xx_ble {
enum class BLEComponentState : uint8_t {
STATE_OFF = 0,
ENABLING,
ACTIVE,
};
/// One advertisement report from the controller.
struct BLEScanReport {
uint8_t mac[6]; // LSB-first, as the controller delivers it
int8_t rssi; // signed dBm
uint8_t addr_type;
uint8_t data_len; // bytes valid in data[]
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
// EventPool contract: nothing is heap-allocated inside a report.
void release() {}
};
/// Consumer interface for controller scan reports. on_scan_report() always runs
/// on the ESPHome main task: reports are queued from the BDK BLE task and
/// drained by the controller's loop(), so consumers never deal with cross-task
/// state (the esp32_ble event-queue pattern).
class BLEScanListener {
public:
virtual void on_scan_report(const BLEScanReport &report) = 0;
protected:
~BLEScanListener() = default; // deletion via this interface is not part of the contract
};
// Maximum reports buffered between the BLE task and loop().
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
class BK72xxBLE final : public Component {
public:
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
/// Bring up the BDK BLE stack (one-time; the BDK has no teardown path).
void enable();
bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; }
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
/// Controller BLE address, least-significant octet first (BLE convention).
void get_mac_lsb_first(uint8_t out[6]) const;
/// Register a consumer for scan reports (delivered on the main task via loop()).
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
/// Enables the stack first if needed. Returns false on controller failure.
bool scan_start(uint16_t interval, uint16_t window);
/// Stop the controller scan (no-op when not scanning).
void scan_stop();
/// Internal: buffer one controller report (BDK notice callback, BLE task
/// context — bounded copy under the scheduler lock, nothing else).
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
protected:
void resolve_mac_();
std::vector<BLEScanListener *> scan_listeners_;
// Report ring: the BDK notice callback (BLE task) allocates a report from the
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
esphome::LockFreeQueue<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE> report_queue_;
// Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so
// allocate() returns nullptr before push() can fail. This prevents leaking a
// pool slot on a failed push and keeps release() off the producer path.
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
uint8_t scan_actv_idx_{0xFF};
BLEComponentState state_{BLEComponentState::STATE_OFF};
bool enable_on_boot_{false};
};
} // namespace esphome::bk72xx_ble
#endif // USE_BK72XX_BLE
@@ -0,0 +1,151 @@
"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable
LibreTiny Beken chips (beken-72xx family).
Builds on the bk72xx_ble controller component (stack bring-up, BLE address,
scan primitives) and implements the platform-neutral ble_device_base BLEHub
contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner,
bthome_mithermometer, xiaomi_*, …) bind to this tracker through
cv.use_id(BLEHub) with no BK-specific code.
Scan modes:
continuous: true — scan runs forever; never stops automatically.
Use this when the radio is dedicated to BLE.
continuous: false — a started scan runs for `duration` ms, then stops. The
FIRST start is external too: nothing in this component
starts a non-continuous scan on boot, so until the
automation actions land (follow-up PR) the radio stays
idle. start_scan() is called from code (e.g. an api
client-connected automation) so the single-core radio
can service WiFi in between scans.
"""
import esphome.codegen as cg
from esphome.components import bk72xx_ble, ble_device_base, ota
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
import esphome.config_validation as cv
from esphome.const import CONF_CONTINUOUS, CONF_DURATION, CONF_ID, CONF_INTERVAL
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
DEPENDENCIES = ["bk72xx"]
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
CODEOWNERS = ["@Bl00d-B0b"]
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
)
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.
Mirrors esp32_ble_tracker: 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 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(
{
cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
}
).extend(cv.COMPONENT_SCHEMA)
# Runs at FINAL priority so every BLE sensor has registered through
# ble_device_base (and any tracker-owned listeners have been counted) before
# the StaticVector size is emitted. Same pattern as esp32_ble_tracker.
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_listener_count() -> None:
count = ble_device_base.get_listener_count()
if count > 0:
cg.add_define("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT", count)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID])
cg.add(var.set_parent(parent))
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
ota.request_ota_state_listeners()
scan = config[CONF_SCAN_PARAMETERS]
cg.add(var.set_scan_interval(to_ble_units(scan[CONF_INTERVAL])))
cg.add(var.set_scan_window(to_ble_units(scan[CONF_WINDOW])))
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
cg.add(var.set_scan_continuous(scan[CONF_CONTINUOUS]))
CORE.add_job(_emit_listener_count)
@@ -0,0 +1,211 @@
// bk72xx_ble_tracker.cpp
//
// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period
// timers and the rate-limited start retry. All controller access (stack
// bring-up, scan primitives, the BLE-task → main-task report queue) goes
// through the bk72xx_ble component — no SDK calls and no cross-task state here.
#ifdef USE_LIBRETINY
#include "bk72xx_ble_tracker.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::bk72xx_ble_tracker {
static const char *const TAG = "bk72xx_ble_tracker";
// Minimum interval between scan (re)start attempts, so a failing controller start
// cannot be retried every main-loop iteration (single-core CPU starvation). The
// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller
// that never comes up — the controller logs each failure at ERROR — settles into a
// slow, quiet poll instead of an error line every second for the rest of uptime;
// a single WARN is emitted when the retry interval first saturates.
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
// ---------------------------------------------------------------------------
// Component lifecycle
// ---------------------------------------------------------------------------
void BK72xxBLETracker::setup() {
// Receive the controller's scan reports; the controller queues them from the
// BLE task and delivers here on the main task.
this->parent_->register_scan_listener(this);
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
ota::get_global_ota_callback()->add_global_state_listener(this);
#endif
}
#ifdef USE_OTA_STATE_LISTENER
void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
ota::OTAComponent *comp) {
if (state == ota::OTA_STARTED) {
this->scan_continuous_before_ota_ = this->scan_continuous_;
this->stop_scan();
} else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) {
// On success the device reboots, so restore only on a failed/aborted update;
// loop() restarts the scan on its next iteration (continuous idle branch).
this->scan_continuous_before_ota_ = false;
this->scan_continuous_ = true;
}
}
#endif // USE_OTA_STATE_LISTENER
void BK72xxBLETracker::loop() {
const uint32_t now = millis();
if (this->scan_continuous_) {
if (!this->scan_running_) {
// 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.
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
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
// that never came up (start kept failing) does not fire spurious on_scan_end events.
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
#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
this->scan_period_start_ = now;
}
return;
}
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
// Restart is driven externally (e.g. api: on_client_connected:).
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
this->stop_scan_();
}
}
void BK72xxBLETracker::dump_config() {
ESP_LOGCONFIG(TAG,
"BK72xx BLE Tracker:\n"
" Scan Duration: %" PRIu32 " s\n"
" Scan Interval: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Window: %.0f ms (%" PRIu32 " BLE units)\n"
" Scan Type: PASSIVE\n"
" Continuous Scanning: %s",
this->scan_duration_ / 1000, this->scan_interval_ * 0.625f, this->scan_interval_,
this->scan_window_ * 0.625f, this->scan_window_, YESNO(this->scan_continuous_));
}
// ---------------------------------------------------------------------------
// Scan report — delivered by the controller's loop() on the ESPHome main task
// (the controller queues reports from the BLE task), so publish_state() and
// listener dispatch run in main-loop context with no cross-task handling here.
// ---------------------------------------------------------------------------
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
// Raw callback (the raw-advertisement path).
if (this->raw_advertisement_callback_.is_set()) {
const ble_device_base::RawAdvertisement adv{.mac = report.mac,
.data = report.data,
.data_len = report.data_len,
.rssi = report.rssi,
.addr_type = report.addr_type};
this->raw_advertisement_callback_.invoke(adv);
}
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
ble_device_base::ESPBTDevice device;
device.from_scan_result(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
bool found = false;
for (auto *listener : this->listeners_)
if (listener->parse_device(device))
found = true;
// Mirror esp32_ble_tracker: log a newly-seen device only when nothing claimed
// it and the scan is one-shot (continuous scans would spam).
if (!found && !this->scan_continuous_)
this->discovered_log_.log_device(TAG, device);
#endif // ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
}
// ---------------------------------------------------------------------------
// Public scan control
// ---------------------------------------------------------------------------
void BK72xxBLETracker::start_scan() {
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
// set_scan_continuous() first, then calls start_scan() to begin scanning.
if (!this->scan_running_) {
this->start_scan_();
}
}
void BK72xxBLETracker::stop_scan() {
this->scan_continuous_ = false;
this->stop_scan_();
}
// ---------------------------------------------------------------------------
// Internal scan start / stop
// ---------------------------------------------------------------------------
void BK72xxBLETracker::start_scan_() {
if (this->scan_running_)
return;
if (!this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
static_cast<uint16_t>(this->scan_window_)))
return;
const uint32_t now = millis();
this->scan_running_ = true;
this->scan_start_time_ = now;
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
// in non-continuous mode each period is an explicit start, so asymmetric logging
// would read as the scanner failing to come back up.
ESP_LOGD(TAG, "Scan started (passive, window=%.0fms, interval=%.0fms)", this->scan_window_ * 0.625f,
this->scan_interval_ * 0.625f);
// Re-anchor the on_scan_end period to every successful start — first start (so the
// period counts from the scan, not from boot) and every restart after a stop (so
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
// mode 10 minutes later, does not fire on_scan_end before an advertisement can
// arrive). scan_started_once_ purely gates the period timer.
this->scan_period_start_ = now;
this->scan_started_once_ = true;
}
void BK72xxBLETracker::stop_scan_() {
if (!this->scan_running_)
return;
this->parent_->scan_stop();
this->scan_running_ = false;
ESP_LOGD(TAG, "Scan stopped");
#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
this->scan_period_start_ = millis(); // reset period clock so on_scan_end does not double-fire
}
} // namespace esphome::bk72xx_ble_tracker
#endif // USE_LIBRETINY
@@ -0,0 +1,150 @@
// bk72xx_ble_tracker.h
//
// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family).
// Implements the platform-neutral ble_device_base::BLEHub contract on top of the
// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered
// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the
// hub's raw-advertisement callback.
//
// This component contains no Beken SDK calls and no cross-task state: the
// controller (stack bring-up, BLE address, scan primitives, and the BLE-task →
// main-task report queue) is owned by bk72xx_ble, which delivers every scan
// report on the ESPHome main task. The tracker owns scan policy — parameters,
// duration/period timers and the rate-limited start retry.
//
// YAML config (values shown are the defaults; interval/window are a 30 % duty
// cycle, the BK reference scan rate):
//
// bk72xx_ble_tracker:
// scan_parameters:
// interval: 100ms
// window: 30ms
// duration: 5min
// continuous: true
#pragma once
#ifdef USE_LIBRETINY
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_hub.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#ifdef USE_OTA_STATE_LISTENER
#include "esphome/components/ota/ota_backend.h"
#endif
namespace esphome::bk72xx_ble_tracker {
// ---------------------------------------------------------------------------
// BK72xxBLETracker
// ---------------------------------------------------------------------------
class BK72xxBLETracker : public Component,
public ble_device_base::BLEHub,
public bk72xx_ble::BLEScanListener,
public Parented<bk72xx_ble::BK72xxBLE>
#ifdef USE_OTA_STATE_LISTENER
,
public ota::OTAGlobalStateListener
#endif
{
public:
// ---- ESPHome Component ----
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
#ifdef USE_OTA_STATE_LISTENER
// Pause scanning while an OTA update runs (single-core WiFi/BLE/flash contention);
// mirrors esp32_ble_tracker.
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
#endif
// ---- YAML configuration setters ----
void set_scan_interval(uint32_t scan_interval) { this->scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { this->scan_window_ = scan_window; }
void set_scan_duration(uint32_t scan_duration) { this->scan_duration_ = scan_duration; }
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
// ---- Public scan control ----
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
void start_scan();
void stop_scan();
// ---- ble_device_base::BLEHub contract ----
void register_listener(ble_device_base::ESPBTDeviceListener *listener) override {
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
this->listeners_.push_back(listener);
#endif
}
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) override {
this->raw_advertisement_callback_ = callback;
}
ble_device_base::HubCapabilities get_capabilities() const override {
// The Beken BDK exposes no active-scan path (passive scanning only), so the
// controller never solicits scan responses and never merges them; consumers
// relying on scan-response fields (device names) get them only where the
// receiver merges per address (Home Assistant does). No GATT client either.
return {.active_scan = false, .merges_scan_response = false, .gatt = false};
}
// The controller stores the address LSB-first (BLE convention); the contract
// wants printable (MSB-first) order.
void get_adapter_mac(uint8_t out[6]) override {
uint8_t mac[6];
this->parent_->get_mac_lsb_first(mac);
for (int i = 0; i < 6; i++)
out[i] = mac[5 - i];
}
bool scan_running() override { return this->scan_running_; }
bool scan_active() override { return false; } // BK72xx scan is passive-only
// ---- bk72xx_ble::BLEScanListener ----
// Delivered by the controller's loop() on the ESPHome main task — the
// BLE-task → main-task handoff already happened in the controller's queue.
void on_scan_report(const bk72xx_ble::BLEScanReport &report) override;
protected:
void start_scan_();
void stop_scan_();
bool scan_running_{false};
// Defaults: the BK reference — 30 % duty cycle
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
uint32_t scan_duration_{300000};
bool scan_continuous_{true};
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
#endif
uint32_t scan_start_time_{0};
uint32_t last_scan_start_attempt_{0}; // millis() of last start_scan_() attempt; rate-limits retries
uint8_t failed_start_count_{0}; // consecutive failed starts; drives the retry backoff (reset on success)
uint32_t scan_period_start_{0}; // millis() at start of current scan period; used to rate-limit on_scan_end()
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
ble_device_base::RawAdvertisementCallback raw_advertisement_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<ble_device_base::ESPBTDeviceListener *, ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT> listeners_;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
// 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
#endif // USE_LIBRETINY
+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_)
node->gattc_event_handler(event, esp_gattc_if, param);
if (!this->services_.empty() && this->all_nodes_established_()) {
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
this->release_services();
ESP_LOGD(TAG, "All clients established, services released");
}
@@ -34,6 +34,11 @@ class BLEClientNode {
// This should be transitioned to Established once the node no longer needs
// the services/descriptors/characteristics of the parent client. This will
// allow some memory to be freed.
// The parent frees the peer's GATT cache once every node reports Established.
// Never report Established while an operation that reads that cache is outstanding.
// - esp_ble_gattc_register_for_notify() completes asynchronously.
// - Register from ESP_GATTC_SEARCH_CMPL_EVT, then set this from ESP_GATTC_REG_FOR_NOTIFY_EVT.
// - BLEClientBase::register_for_notify() holds the release until the registration completes.
espbt::ClientState node_state;
BLEClient *parent() { return this->parent_; }
@@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
this->handle = descr->handle;
}
if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
this->parent()->get_remote_bda(), chr->handle);
auto status = this->parent()->register_for_notify(chr->handle);
if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
}
@@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->handle = descr->handle;
}
if (this->notify_) {
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
this->parent()->get_remote_bda(), chr->handle);
auto status = this->parent()->register_for_notify(chr->handle);
if (status) {
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
}
@@ -0,0 +1,134 @@
"""
ble_device_base — the platform-neutral BLE layer.
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
(BLEHub, in ble_hub.h) on every platform.
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
subclass, so there is no platform table here and no dependency in either
direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and
calls register_ble_device() in to_code; a tracker component subclasses BLEHub
(C++ and codegen class). Adding a new BLE chip requires only a new tracker
component.
AES-CCM decryption for encrypted advertisements is provided portably in
ble_aes_ccm.h.
"""
import re
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@Bl00d-B0b"]
CONF_BLE_HUB_ID = "ble_hub_id"
# CORE.data key: number of parsed-advertisement listeners registered in this
# build. Trackers whose codegen sizes storage at compile time (esp32's
# StaticVector count define) read it in their final coroutine.
KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count"
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
# The neutral tracker contract. Every tracker's codegen class declares this as a
# parent, which is what lets cv.use_id(BLEHub) resolve any of them.
BLEHub = ble_device_base_ns.class_("BLEHub")
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
def inject_ble_hub(config: ConfigType) -> ConfigType:
"""Validator: auto-resolve the configured BLE tracker into the config.
Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID +
cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on
any platform; multiple trackers can be disambiguated with an explicit
ble_hub_id.
"""
return cv.Schema(
{cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA
)(config)
def request_irk_support() -> None:
"""Compile in resolve_irk()'s software-AES path. Called by sensors with an
irk: option so builds without IRK do not carry the resolution code."""
cg.add_define("USE_BLE_DEVICE_IRK")
def get_listener_count() -> int:
"""Number of parsed listeners registered so far (for tracker codegen)."""
return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0)
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
"""Register `var` as a parsed-advertisement listener on the configured hub."""
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
cg.add(hub.register_listener(var))
CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1
return var
# ---- shared validation / codegen helpers (platform-neutral) ----
BT_UUID16_FORMAT = "XXXX"
BT_UUID32_FORMAT = "XXXXXXXX"
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$")
_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$")
_BT_UUID128_RE = re.compile(
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
)
# Validator table keyed by input length: (compiled pattern, label used in errors).
_BT_UUID_FORMATS = {
len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"),
len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"),
len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"),
}
def bt_uuid(value: str) -> str:
in_value = cv.string_strict(value)
value = in_value.upper()
fmt = _BT_UUID_FORMATS.get(len(value))
if fmt is None:
raise cv.Invalid(
f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format"
)
pattern, label = fmt
if not pattern.match(value):
raise cv.Invalid(
f"Invalid hexadecimal value for {label} UUID format: '{in_value}'"
)
return value
def as_hex(value: str) -> cg.RawExpression:
return cg.RawExpression(f"0x{value}ULL")
def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression:
value = value.replace("-", "")
cpp_array = [
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
]
if reverse:
cpp_array.reverse()
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
def as_hex_array(value: str) -> cg.RawExpression:
return _hex_array_expression(value, reverse=False)
def as_reversed_hex_array(value: str) -> cg.RawExpression:
return _hex_array_expression(value, reverse=True)
@@ -0,0 +1,202 @@
#include "ble_aes_ccm.h"
#include <algorithm>
#include <cstring>
namespace esphome::ble_device_base {
namespace {
// AES-128 forward cipher only — CCM uses the block cipher in the encrypt
// direction for both the CTR keystream and the CBC-MAC.
const uint8_t SBOX[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, //
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, //
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, //
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, //
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, //
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, //
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, //
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, //
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, //
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, //
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, //
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, //
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, //
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, //
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, //
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, //
};
const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36};
inline uint8_t xtime(uint8_t x) { return static_cast<uint8_t>((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); }
// AES-128 forward cipher with on-the-fly key schedule.
class Aes128 {
public:
explicit Aes128(const uint8_t key[16]) {
memcpy(this->rk_, key, 16);
for (size_t i = 16; i < 176; i += 4) {
uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]};
if (i % 16 == 0) {
const uint8_t tmp = t[0];
t[0] = static_cast<uint8_t>(SBOX[t[1]] ^ RCON[i / 16]);
t[1] = SBOX[t[2]];
t[2] = SBOX[t[3]];
t[3] = SBOX[tmp];
}
for (size_t j = 0; j < 4; j++)
this->rk_[i + j] = static_cast<uint8_t>(this->rk_[i - 16 + j] ^ t[j]);
}
}
void encrypt(const uint8_t in[16], uint8_t out[16]) const {
uint8_t s[16];
memcpy(s, in, 16);
for (size_t i = 0; i < 16; i++)
s[i] ^= this->rk_[i];
for (size_t round = 1; round < 10; round++) {
for (uint8_t &b : s)
b = SBOX[b];
shift_rows(s);
for (size_t c = 0; c < 4; c++) {
uint8_t *col = s + c * 4;
const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3];
const uint8_t h = static_cast<uint8_t>(a0 ^ a1 ^ a2 ^ a3);
col[0] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a0 ^ a1)));
col[1] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a1 ^ a2)));
col[2] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a2 ^ a3)));
col[3] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a3 ^ a0)));
}
for (size_t i = 0; i < 16; i++)
s[i] ^= this->rk_[round * 16 + i];
}
for (uint8_t &b : s)
b = SBOX[b];
shift_rows(s);
for (size_t i = 0; i < 16; i++)
s[i] ^= this->rk_[160 + i];
memcpy(out, s, 16);
}
protected:
static void shift_rows(uint8_t s[16]) {
uint8_t t = s[1];
s[1] = s[5];
s[5] = s[9];
s[9] = s[13];
s[13] = t;
t = s[2];
s[2] = s[10];
s[10] = t;
t = s[6];
s[6] = s[14];
s[14] = t;
t = s[3];
s[3] = s[15];
s[15] = s[11];
s[11] = s[7];
s[7] = t;
}
uint8_t rk_[176];
};
} // namespace
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) {
Aes128 aes(key);
aes.encrypt(in, out);
}
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
const uint8_t *tag, size_t tag_len) {
// CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte
// nonce L = 2; BTHome uses M = 4.
if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16)
return false;
const size_t l = 15 - nonce_len;
const size_t m = tag_len;
const Aes128 aes(key);
// Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian).
uint8_t a[16];
auto build_ctr = [&](uint32_t counter) {
a[0] = static_cast<uint8_t>(l - 1);
memcpy(a + 1, nonce, nonce_len);
memset(a + 1 + nonce_len, 0, l);
for (size_t i = 0; i < l; i++)
a[15 - i] = static_cast<uint8_t>((counter >> (8 * i)) & 0xff);
};
// S_0 = E(A_0); its first m bytes mask the transmitted tag.
uint8_t s0[16];
build_ctr(0);
aes.encrypt(a, s0);
// CTR-decrypt ciphertext into plaintext using S_1, S_2, ...
uint8_t ks[16];
for (size_t off = 0; off < ct_len; off += 16) {
build_ctr(static_cast<uint32_t>(off / 16) + 1);
aes.encrypt(a, ks);
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
for (size_t i = 0; i < n; i++)
plaintext[off + i] = static_cast<uint8_t>(ciphertext[off + i] ^ ks[i]);
}
// CBC-MAC over B_0 | (formatted AAD) | plaintext.
uint8_t x[16];
uint8_t b0[16];
const uint8_t flags = static_cast<uint8_t>((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1));
b0[0] = flags;
memcpy(b0 + 1, nonce, nonce_len);
memset(b0 + 1 + nonce_len, 0, l);
for (size_t i = 0; i < l; i++)
b0[15 - i] = static_cast<uint8_t>((ct_len >> (8 * i)) & 0xff);
aes.encrypt(b0, x); // X_1 = E(B_0)
if (aad_len > 0) {
// Only the < 2^16-2^8 encoding is needed for BLE-sized AAD.
uint8_t blk[16] = {0};
blk[0] = static_cast<uint8_t>((aad_len >> 8) & 0xff);
blk[1] = static_cast<uint8_t>(aad_len & 0xff);
size_t ai = 0;
size_t pos = 2;
while (pos < 16 && ai < aad_len)
blk[pos++] = aad[ai++];
for (size_t i = 0; i < 16; i++)
x[i] ^= blk[i];
aes.encrypt(x, x);
while (ai < aad_len) {
memset(blk, 0, 16);
const size_t n = std::min(static_cast<size_t>(16), aad_len - ai);
memcpy(blk, aad + ai, n);
ai += n;
for (size_t i = 0; i < 16; i++)
x[i] ^= blk[i];
aes.encrypt(x, x);
}
}
for (size_t off = 0; off < ct_len; off += 16) {
uint8_t blk[16] = {0};
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
memcpy(blk, plaintext + off, n);
for (size_t i = 0; i < 16; i++)
x[i] ^= blk[i];
aes.encrypt(x, x);
}
// Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag.
uint8_t diff = 0;
for (size_t i = 0; i < m; i++)
diff |= static_cast<uint8_t>((x[i] ^ s0[i]) ^ tag[i]);
return diff == 0;
}
} // namespace esphome::ble_device_base
@@ -0,0 +1,34 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace esphome::ble_device_base {
// Self-contained AES-128-CCM authenticated decryption (RFC 3610).
//
// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use
// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE
// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls
// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not
// put it on the application include path), so a sensor cannot rely on
// <mbedtls/ccm.h> being available. This software implementation makes
// encrypted-advertisement decryption work on every BLE platform without a
// per-chip crypto dependency. Decryption volume is tiny (one short block per
// matching advertisement), so software AES is not a meaningful cost.
//
// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted
// bytes to `plaintext` and returns true. Returns false when authentication fails
// (the caller must then discard `plaintext`). The CCM parameters follow the
// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be
// null when `aad_len` is 0.
/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by
/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching
/// works identically on every platform with no chip crypto dependency.
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
const uint8_t *tag, size_t tag_len);
} // namespace esphome::ble_device_base
@@ -0,0 +1,530 @@
// ble_device.cpp
//
// Platform-neutral implementation of the shared BLE advertisement types.
// Parses raw BLE advertisement data into ESPBTDevice.
#include "ble_device.h"
#include "ble_aes_ccm.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cstring>
namespace esphome::ble_device_base {
static const char *const TAG = "ble_device_base";
// Longest advertisement payload worth hex-dumping at VERY_VERBOSE
// (legacy advertising: 31-byte adv + 31-byte scan response).
static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62;
// ---------------------------------------------------------------------------
// ESPBTUUID
// ---------------------------------------------------------------------------
ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) {
ESPBTUUID ret;
ret.type_ = Type::UUID16;
ret.uuid_.uuid16 = uuid;
return ret;
}
ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) {
ESPBTUUID ret;
ret.type_ = Type::UUID32;
ret.uuid_.uuid32 = uuid;
return ret;
}
ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) {
ESPBTUUID ret;
ret.type_ = Type::UUID128;
memcpy(ret.uuid_.uuid128, data, 16);
return ret;
}
ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) {
ESPBTUUID ret;
ret.type_ = Type::UUID128;
for (int i = 0; i < 16; i++)
ret.uuid_.uuid128[i] = data[15 - i];
return ret;
}
ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
// Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw.
ESPBTUUID ret;
if (length == 4) {
// 16-bit UUID as 4-character hex string
auto parsed = parse_hex<uint16_t>(data, length);
if (parsed.has_value()) {
ret.type_ = Type::UUID16;
ret.uuid_.uuid16 = parsed.value();
}
} else if (length == 8) {
// 32-bit UUID as 8-character hex string
auto parsed = parse_hex<uint32_t>(data, length);
if (parsed.has_value()) {
ret.type_ = Type::UUID32;
ret.uuid_.uuid32 = parsed.value();
}
} else if (length == 16) {
// 16 raw bytes (little-endian 128-bit UUID)
ret.type_ = Type::UUID128;
memcpy(ret.uuid_.uuid128, reinterpret_cast<const uint8_t *>(data), 16);
} else if (length == 36) {
// Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
ret.type_ = Type::UUID128;
int n = 0;
for (size_t i = 0; i < length; i += 2) {
if (data[i] == '-')
i++;
uint8_t msb = data[i];
uint8_t lsb = data[i + 1];
if (msb > '9')
msb -= 7;
if (lsb > '9')
lsb -= 7;
ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F);
}
} else {
ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data);
}
return ret;
}
#ifdef USE_ESP32
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
if (uuid.len == ESP_UUID_LEN_16)
return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
if (uuid.len == ESP_UUID_LEN_32)
return ESPBTUUID::from_uint32(uuid.uuid.uuid32);
return ESPBTUUID::from_raw(uuid.uuid.uuid128);
}
esp_bt_uuid_t ESPBTUUID::get_uuid() const {
esp_bt_uuid_t ret;
switch (this->type_) {
case Type::UUID16:
ret.len = ESP_UUID_LEN_16;
ret.uuid.uuid16 = this->uuid_.uuid16;
break;
case Type::UUID32:
ret.len = ESP_UUID_LEN_32;
ret.uuid.uuid32 = this->uuid_.uuid32;
break;
default:
case Type::UUID128:
ret.len = ESP_UUID_LEN_128;
memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128);
break;
}
return ret;
}
void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
this->scan_result_ = &scan_result;
// BLEScanResult's bda is most-significant octet first; the neutral ingest
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
// address_str() then produce exactly the historical esp32 values.
uint8_t mac_lsb_first[6];
for (uint8_t i = 0; i < 6; i++)
mac_lsb_first[i] = scan_result.bda[5 - i];
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
scan_result.adv_data_len + scan_result.scan_rsp_len);
}
#endif // USE_ESP32
ESPBTUUID ESPBTUUID::as_128bit() const {
if (this->type_ == Type::UUID128)
return *this;
uint8_t data[16];
this->to_128bit_(data);
return ESPBTUUID::from_raw(data);
}
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
switch (this->type_) {
case Type::UUID16:
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
case Type::UUID32:
for (uint8_t i = 0; i < 3; i++) {
bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1;
bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2;
if (a && b)
return true;
}
return false;
case Type::UUID128:
for (uint8_t i = 0; i < 15; i++) {
if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2)
return true;
}
return false;
}
return false;
}
const char *ESPBTUUID::to_str(char *buf) const {
// Identical output format to esp32_ble::ESPBTUUID::to_str.
char *pos = buf;
switch (this->type_) {
case Type::UUID16:
*pos++ = '0';
*pos++ = 'x';
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12);
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F);
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F);
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F);
*pos = 0; // NUL-terminate
return buf;
case Type::UUID32:
*pos++ = '0';
*pos++ = 'x';
for (int shift = 28; shift >= 0; shift -= 4)
*pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F);
*pos = 0; // NUL-terminate
return buf;
default:
case Type::UUID128:
// Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
for (int8_t i = 15; i >= 0; i--) {
uint8_t byte = this->uuid_.uuid128[i];
*pos++ = format_hex_pretty_char(byte >> 4);
*pos++ = format_hex_pretty_char(byte & 0x0F);
if (i == 12 || i == 10 || i == 8 || i == 6)
*pos++ = '-';
}
*pos = 0; // NUL-terminate
return buf;
}
}
void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
// Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit
// value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit().
static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
if (this->type_ == Type::UUID128) {
memcpy(out, this->uuid_.uuid128, 16);
return;
}
memcpy(out, BASE, 16);
const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16;
const size_t len = (this->type_ == Type::UUID32) ? 4 : 2;
for (size_t i = 0; i < len; i++)
out[12 + i] = (value >> (i * 8)) & 0xFF;
}
bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
if (this->type_ == other.type_) {
switch (this->type_) {
case Type::UUID16:
return this->uuid_.uuid16 == other.uuid_.uuid16;
case Type::UUID32:
return this->uuid_.uuid32 == other.uuid_.uuid32;
case Type::UUID128:
return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0;
}
return false;
}
// Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a
// configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
uint8_t a[16];
uint8_t b[16];
this->to_128bit_(a);
other.to_128bit_(b);
return memcmp(a, b, 16) == 0;
}
// ---------------------------------------------------------------------------
// ESPBLEiBeacon
// ---------------------------------------------------------------------------
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) {
// iBeacon manufacturer specific data (after company-ID bytes have been stripped):
// [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes
// Parity with esp32_ble_tracker: gate on the Apple company ID and length only.
// (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior
// change; it belongs to a follow-up, not this refactor.)
if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
return {};
if (data.data.size() != 23)
return {};
return ESPBLEiBeacon(data.data.data());
}
// ---------------------------------------------------------------------------
// ESPBTDevice
// ---------------------------------------------------------------------------
const char *ESPBTDevice::address_type_str() const {
switch (this->address_type_) {
case BLE_ADDR_TYPE_PUBLIC:
return "PUBLIC";
case BLE_ADDR_TYPE_RANDOM:
return "RANDOM";
case BLE_ADDR_TYPE_RPA_PUBLIC:
return "RPA_PUBLIC";
case BLE_ADDR_TYPE_RPA_RANDOM:
return "RPA_RANDOM";
default:
return "UNKNOWN";
}
}
void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data,
uint16_t data_len) {
// Ingest is BLE controller order (LSB-first); store in printable (MSB-first)
// order so the raw address() accessor matches the historical esp32 layout.
for (uint8_t i = 0; i < 6; i++)
this->address_[i] = mac[5 - i];
this->address_type_ = addr_type;
this->rssi_ = rssi;
this->name_.clear();
this->service_uuids_.clear();
this->manufacturer_datas_.clear();
this->service_datas_.clear();
this->tx_powers_.clear();
this->appearance_.reset();
this->ad_flag_.reset();
this->parse_adv_(data, data_len);
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGVV(TAG,
"Parse Result:\n"
" Address: %s (%s)\n"
" RSSI: %d\n"
" Name: '%s'",
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str());
for (auto &it : this->tx_powers_) {
ESP_LOGVV(TAG, " TX Power: %d", it);
}
if (this->appearance_.has_value()) {
ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_);
}
if (this->ad_flag_.has_value()) {
ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_);
}
char uuid_buf[UUID_STR_LEN];
for (auto &uuid : this->service_uuids_) {
ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf));
}
char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)];
for (auto &mfg_data : this->manufacturer_datas_) {
auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data);
if (ibeacon.has_value()) {
ESP_LOGVV(TAG,
" Manufacturer iBeacon:\n"
" UUID: %s\n"
" Major: %u\n"
" Minor: %u\n"
" TXPower: %d",
ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(),
ibeacon.value().get_signal_power());
} else {
ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf),
format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size()));
}
}
for (auto &svc_data : this->service_datas_) {
ESP_LOGVV(TAG,
" Service data:\n"
" UUID: %s\n"
" Data: %s",
svc_data.uuid.to_str(uuid_buf),
format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size()));
}
ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len));
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE
}
std::string ESPBTDevice::address_str() const {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->address_str_to(buf));
}
const char *ESPBTDevice::address_str_to(char *buf) const {
// address_ is stored in printable (MSB-first) order.
format_mac_addr_upper(this->address_, buf);
return buf;
}
uint64_t ESPBTDevice::address_uint64() const {
// address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics).
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
addr |= static_cast<uint64_t>(this->address_[i]) << ((5 - i) * 8);
return addr;
}
bool ESPBTDevice::resolve_irk(const uint8_t *irk) const {
#ifdef USE_BLE_DEVICE_IRK
// Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits].
// The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes).
// Uses the portable software AES-128 shared with the CCM decryptor, so IRK
// matching behaves identically on every platform (volume is one block per
// advertisement from a matching RPA device — software AES is not a cost).
uint8_t ecb_plaintext[16] = {0};
uint8_t ecb_ciphertext[16];
const uint64_t addr64 = this->address_uint64();
ecb_plaintext[13] = (addr64 >> 40) & 0xff;
ecb_plaintext[14] = (addr64 >> 32) & 0xff;
ecb_plaintext[15] = (addr64 >> 24) & 0xff;
aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext);
return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) &&
ecb_ciphertext[13] == ((addr64 >> 16) & 0xff);
#else
// No sensor configured an irk: in this build; the AES core is compiled out.
(void) irk;
return false;
#endif
}
void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
// BLE AD structure TLV: [length][type][value...]
// length includes the type byte.
uint16_t offset = 0;
while (offset < len) {
uint8_t ad_len = payload[offset++];
if (ad_len == 0)
continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too)
if (offset + ad_len > len)
break;
uint8_t ad_type = payload[offset];
const uint8_t *ad_data = &payload[offset + 1];
uint8_t ad_data_len = ad_len - 1;
offset += ad_len;
switch (ad_type) {
case 0x01: // Flags
if (ad_data_len >= 1)
this->ad_flag_ = ad_data[0];
break;
case 0x08: // Shortened Local Name
case 0x09: // Complete Local Name
// Keep the longest name seen — a merged adv + scan-response frame may carry both the
// shortened and the complete name, and the shortened form must never replace the
// complete one (same rule as esp32_ble_tracker's parse_adv_).
if (ad_data_len > this->name_.length())
this->name_.assign(reinterpret_cast<const char *>(ad_data), ad_data_len);
break;
case 0x0A: // TX Power Level
if (ad_data_len >= 1)
this->tx_powers_.push_back(static_cast<int8_t>(ad_data[0]));
break;
case 0x19: // Appearance
if (ad_data_len >= 2)
this->appearance_ = static_cast<uint16_t>(ad_data[0]) | (static_cast<uint16_t>(ad_data[1]) << 8);
break;
case 0x02: // Incomplete List of 16-bit Service UUIDs
case 0x03: // Complete List of 16-bit Service UUIDs
for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) {
uint16_t uuid = (static_cast<uint16_t>(ad_data[i + 1]) << 8) | ad_data[i];
this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid));
}
break;
case 0x04: // Incomplete List of 32-bit Service UUIDs
case 0x05: // Complete List of 32-bit Service UUIDs
for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) {
uint32_t uuid = (static_cast<uint32_t>(ad_data[i + 3]) << 24) |
(static_cast<uint32_t>(ad_data[i + 2]) << 16) | (static_cast<uint32_t>(ad_data[i + 1]) << 8) |
ad_data[i];
this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid));
}
break;
case 0x06: // Incomplete List of 128-bit Service UUIDs
case 0x07: // Complete List of 128-bit Service UUIDs
for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16)
this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i]));
break;
case 0xFF: // Manufacturer Specific Data
if (ad_data_len >= 2) {
uint16_t company_id = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
ServiceData sd;
sd.uuid = ESPBTUUID::from_uint16(company_id);
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
this->manufacturer_datas_.push_back(std::move(sd));
}
break;
case 0x16: // Service Data — 16-bit UUID
if (ad_data_len >= 2) {
uint16_t uuid = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
ServiceData sd;
sd.uuid = ESPBTUUID::from_uint16(uuid);
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
this->service_datas_.push_back(std::move(sd));
}
break;
case 0x20: // Service Data — 32-bit UUID
if (ad_data_len >= 4) {
uint32_t uuid = (static_cast<uint32_t>(ad_data[3]) << 24) | (static_cast<uint32_t>(ad_data[2]) << 16) |
(static_cast<uint32_t>(ad_data[1]) << 8) | ad_data[0];
ServiceData sd;
sd.uuid = ESPBTUUID::from_uint32(uuid);
sd.data.assign(ad_data + 4, ad_data + ad_data_len);
this->service_datas_.push_back(std::move(sd));
}
break;
case 0x21: // Service Data — 128-bit UUID
if (ad_data_len >= 16) {
ServiceData sd;
sd.uuid = ESPBTUUID::from_raw(ad_data);
sd.data.assign(ad_data + 16, ad_data + ad_data_len);
this->service_datas_.push_back(std::move(sd));
}
break;
default:
break;
}
}
}
// ---------------------------------------------------------------------------
// DiscoveredDeviceLog
// ---------------------------------------------------------------------------
void DiscoveredDeviceLog::log_device(const char *tag, const ESPBTDevice &device) {
#ifdef ESPHOME_LOG_HAS_DEBUG
// Everything here feeds ESP_LOGD: below DEBUG the whole body (including the
// dedup vector growth) would be pure overhead, so compile it out entirely.
const uint64_t address = device.address_uint64();
for (auto &disc : this->already_discovered_) {
if (disc == address)
return;
}
this->already_discovered_.push_back(address);
char addr_buf[ESPBTDevice::MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD(tag,
"Found device %s RSSI=%d\n"
" Address Type: %s",
device.address_str_to(addr_buf), device.get_rssi(), device.address_type_str());
if (!device.get_name().empty()) {
ESP_LOGD(tag, " Name: '%s'", device.get_name().c_str());
}
for (auto &tx_power : device.get_tx_powers()) {
ESP_LOGD(tag, " TX Power: %d", tx_power);
}
#endif // ESPHOME_LOG_HAS_DEBUG
}
} // namespace esphome::ble_device_base
@@ -0,0 +1,274 @@
// ble_device.h
//
// Platform-neutral BLE advertisement types — the generic base every BLE consumer
// (sensor components, bluetooth_proxy, automation triggers) builds against:
// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener
//
// These types are owned here on EVERY platform, with no chip-SDK types in their
// public surface. Platform trackers produce them:
// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and
// re-exports these names (esp32 only) for backward compatibility;
// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly.
#pragma once
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <string>
#include <vector>
#if defined(__cpp_lib_span)
#include <span>
#endif
#ifdef USE_ESP32
// Historical esp32_ble API surface (below, under the same define) uses the
// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32.
#include "esphome/components/esp32_ble/ble_scan_result.h"
#include <esp_bt_defs.h>
#endif
namespace esphome::ble_device_base {
using adv_data_t = std::vector<uint8_t>;
// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t).
static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0;
static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1;
static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2;
static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3;
/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0"
static constexpr size_t UUID_STR_LEN = 37;
// ---------------------------------------------------------------------------
// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type.
// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t
// conversions live in esp32_ble (esp32-only adapters), not here.
// ---------------------------------------------------------------------------
class ESPBTUUID {
public:
ESPBTUUID() = default;
static ESPBTUUID from_uint16(uint16_t uuid);
static ESPBTUUID from_uint32(uint32_t uuid);
/// Construct from raw 16-byte little-endian UUID.
static ESPBTUUID from_raw(const uint8_t *data);
/// Construct from raw 16-byte big-endian UUID (reversed on store).
static ESPBTUUID from_raw_reversed(const uint8_t *data);
/// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes,
/// or the 36-char dashed UUID form. Same semantics as esp32_ble historically.
static ESPBTUUID from_raw(const char *data, size_t length);
static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); }
static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); }
static ESPBTUUID from_raw(std::initializer_list<uint8_t> data) {
return from_raw(reinterpret_cast<const char *>(data.begin()), data.size());
}
#ifdef USE_ESP32
/// Source compatibility with the historical esp32_ble API (esp32 builds only).
static ESPBTUUID from_uuid(esp_bt_uuid_t uuid);
esp_bt_uuid_t get_uuid() const;
#endif
/// Expand to the 128-bit Bluetooth Base UUID form.
ESPBTUUID as_128bit() const;
/// True if the UUID value contains the adjacent byte pair (data1, data2).
bool contains(uint8_t data1, uint8_t data2) const;
bool operator==(const ESPBTUUID &other) const;
bool operator!=(const ESPBTUUID &other) const { return !(*this == other); }
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf
/// (>= UUID_STR_LEN bytes) and return buf.
const char *to_str(char *buf) const;
#if defined(__cpp_lib_span)
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
#endif
enum class Type : uint8_t { UUID16, UUID32, UUID128 };
Type type() const { return this->type_; }
uint16_t uuid16() const { return this->uuid_.uuid16; }
uint32_t uuid32() const { return this->uuid_.uuid32; }
const uint8_t *uuid128() const { return this->uuid_.uuid128; }
protected:
// Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
void to_128bit_(uint8_t out[16]) const;
Type type_{Type::UUID16};
union {
uint16_t uuid16;
uint32_t uuid32;
uint8_t uuid128[16];
} uuid_{};
};
// ---------------------------------------------------------------------------
// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types)
// ---------------------------------------------------------------------------
struct ServiceData {
ESPBTUUID uuid;
adv_data_t data;
};
// ---------------------------------------------------------------------------
// ESPBLEiBeacon
// ---------------------------------------------------------------------------
class ESPBLEiBeacon {
public:
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
explicit ESPBLEiBeacon(const uint8_t *data);
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data);
uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
int8_t get_signal_power() const { return this->beacon_data_.signal_power; }
ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); }
protected:
struct PACKED BeaconData {
uint8_t sub_type;
uint8_t length;
uint8_t proximity_uuid[16];
uint16_t major;
uint16_t minor;
int8_t signal_power;
} beacon_data_;
};
/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks.
///
/// The result is the printable-order value esp32 has always sent
/// (esp32_ble::ble_addr_to_uint64), so both proxy paths agree on the wire.
/// 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) {
uint64_t addr = 0;
for (int i = 0; i < 6; i++)
addr |= static_cast<uint64_t>(mac[i]) << (i * 8);
return addr;
}
// ---------------------------------------------------------------------------
// ESPBTDevice — parsed BLE advertisement
// ---------------------------------------------------------------------------
class ESPBTDevice {
public:
/// Populate from a raw scan result delivered by a BLE tracker backend.
/// mac is least-significant octet first (BLE controller convention).
void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
// Alias the core constant so the two cannot drift apart.
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE;
/// Return MAC as "XX:XX:XX:XX:XX:XX" string.
std::string address_str() const;
/// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf.
const char *address_str_to(char *buf) const;
#if defined(__cpp_lib_span)
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
return this->address_str_to(buf.data());
}
#endif
/// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64).
uint64_t address_uint64() const;
/// Raw MAC bytes in printable (MSB-first) order — matches the historical
/// esp32 layout (ESP-IDF bda order).
const uint8_t *address() const { return address_; }
#ifdef USE_ESP32
// Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t.
esp_ble_addr_type_t get_address_type() const { return static_cast<esp_ble_addr_type_t>(this->address_type_); }
/// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result.
void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
// Exposed through a function for use in lambdas
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
#else
uint8_t get_address_type() const { return this->address_type_; }
#endif
/// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or
/// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above.
const char *address_type_str() const;
int get_rssi() const { return rssi_; }
const std::string &get_name() const { return name_; }
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_service_datas() const { return service_datas_; }
const std::vector<int8_t> &get_tx_powers() const { return tx_powers_; }
const optional<uint16_t> &get_appearance() const { return appearance_; }
const optional<uint8_t> &get_ad_flag() const { return ad_flag_; }
/// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah"
/// function, AES-128). Uses the portable software AES shared with the CCM
/// decryptor; compiled only when a sensor configures irk: (request_irk_support).
bool resolve_irk(const uint8_t *irk) const;
optional<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:
void parse_adv_(const uint8_t *payload, uint16_t len);
uint8_t address_[6]{0};
uint8_t address_type_{0};
int rssi_{0};
std::string name_{};
std::vector<ESPBTUUID> service_uuids_{};
std::vector<ServiceData> manufacturer_datas_{};
std::vector<ServiceData> service_datas_{};
#ifdef USE_ESP32
const esp32_ble::BLEScanResult *scan_result_{nullptr};
#endif
std::vector<int8_t> tx_powers_{};
optional<uint16_t> appearance_{};
optional<uint8_t> ad_flag_{};
};
// ---------------------------------------------------------------------------
// DiscoveredDeviceLog — shared per-scan-period "Found device" DEBUG logger
// ---------------------------------------------------------------------------
/// Per-scan-period "Found device" DEBUG logger, deduplicated by MAC address.
/// Shared by all tracker backends so the output format and dedup behaviour stay
/// identical by construction (single implementation instead of per-chip copies).
class DiscoveredDeviceLog {
public:
/// Log the device at DEBUG the first time its MAC is seen this scan period.
void log_device(const char *tag, const ESPBTDevice &device);
/// Reset the per-period dedup list (call when a scan period ends).
void clear() { this->already_discovered_.clear(); }
protected:
std::vector<uint64_t> already_discovered_;
};
// ---------------------------------------------------------------------------
// ESPBTDeviceListener — base class for BLE consumers (sensors, proxy, triggers)
// ---------------------------------------------------------------------------
class ESPBTDeviceListener {
public:
virtual ~ESPBTDeviceListener() = default;
/// Called at the end of each scan duration period.
virtual void on_scan_end() {}
virtual bool parse_device(const ESPBTDevice &device) = 0;
};
} // namespace esphome::ble_device_base

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