mirror of
https://github.com/esphome/esphome.git
synced 2026-09-30 16:30:22 +00:00
Merge remote-tracking branch 'origin/dev' into feat/nrf52_pwm
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
# Normalize line endings to LF in the repository
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.apng binary
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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') }}
|
||||
@@ -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'
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-3
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(){};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
|
||||
|
||||
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
|
||||
uint8_t value;
|
||||
if (write(®, 1) != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Writing register failed!");
|
||||
return 0;
|
||||
}
|
||||
if (read(&value, 1) != i2c::ERROR_OK) {
|
||||
if (!this->read_byte(reg, &value)) {
|
||||
ESP_LOGW(TAG, "Reading register failed!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user